mirror of
https://github.com/home-assistant/core.git
synced 2025-07-26 22:57:17 +00:00
Add madvr envy integration (#120382)
* feat: Add madvr envy * fix: await and pass entry directly * fix: add attributes and unique id for sensors * fix: reflect power state well, improve state detection * fix: don't connect on init, add options, add reload on change, keep on during test * fix: cancel tasks on unload * fix: test connection via library * fix: wait for boot time * docs: add readme and license * fix: broken pipe in lib * fix: detect out of band power off * fix: improve extra attributes * fix: fix unloading, add config flow test, limit to one platform * fix: use conf, refresh coordinator, other comments * fix: remove event data * fix: fix tests passing, remove wake on lan * fix: dont allow to proceed unless connection works * chore: update dep * fix: update config flow, add constants * fix: write state, use runtime data instead * fix: remove await * fix: move unloading and stuff to coordinator/init * fix: pass in config entry with correct type * fix: move queue and tasks to library * fix: config flow error flow, tests, name, and update lib * fix: update lib, leave connection open on setup * fix: update lib * fix: address comments, remove wol from lib * fix: remove unneeded options * fix: remove fields * fix: simplify code, address comments * fix: move error to lib * fix: fix test * fix: stronger types * fix: update lib * fix: missing text from options flow * chore: remove options flow * chore: remove import * chore: update comments * fix: get mac from device, persist * fix: add mac stuff to test * fix: startup import errors * chore: stale comment * fix: get mac from persisted config * chore: update lib * fix: persist mac in a better way * feat: use mac as unique ID for entry * fix: use unique ID from mac, add proper device * fix: will not be set in init potentially * fix: access mac * fix: optimize, move error to lib * feat: add coordinator test, use conf * fix: use one mock, add init test * fix: not async * feat: add remote test * fix: types * fix: patch client, expand remote tests * fix: use snapshot test * fix: update branding * fix: add description, fix type check * fix: update tests * fix: test * fix: update test * fix: camelcase * Fix * feat: strict typing * fix: strict typing in lib * fix: type will never be None * fix: reference to mac, all tests passing --------- Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
parent
8110b60fc9
commit
12228d8a00
@ -287,6 +287,7 @@ homeassistant.components.logger.*
|
|||||||
homeassistant.components.london_underground.*
|
homeassistant.components.london_underground.*
|
||||||
homeassistant.components.lookin.*
|
homeassistant.components.lookin.*
|
||||||
homeassistant.components.luftdaten.*
|
homeassistant.components.luftdaten.*
|
||||||
|
homeassistant.components.madvr.*
|
||||||
homeassistant.components.mailbox.*
|
homeassistant.components.mailbox.*
|
||||||
homeassistant.components.map.*
|
homeassistant.components.map.*
|
||||||
homeassistant.components.mastodon.*
|
homeassistant.components.mastodon.*
|
||||||
|
@ -827,6 +827,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151
|
/tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151
|
||||||
/homeassistant/components/lyric/ @timmo001
|
/homeassistant/components/lyric/ @timmo001
|
||||||
/tests/components/lyric/ @timmo001
|
/tests/components/lyric/ @timmo001
|
||||||
|
/homeassistant/components/madvr/ @iloveicedgreentea
|
||||||
|
/tests/components/madvr/ @iloveicedgreentea
|
||||||
/homeassistant/components/mastodon/ @fabaff
|
/homeassistant/components/mastodon/ @fabaff
|
||||||
/homeassistant/components/matrix/ @PaarthShah
|
/homeassistant/components/matrix/ @PaarthShah
|
||||||
/tests/components/matrix/ @PaarthShah
|
/tests/components/matrix/ @PaarthShah
|
||||||
|
70
homeassistant/components/madvr/__init__.py
Normal file
70
homeassistant/components/madvr/__init__.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""The madvr-envy integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from madvr.madvr import Madvr
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
|
||||||
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
|
|
||||||
|
from .coordinator import MadVRCoordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.REMOTE]
|
||||||
|
|
||||||
|
|
||||||
|
type MadVRConfigEntry = ConfigEntry[MadVRCoordinator]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_handle_unload(coordinator: MadVRCoordinator) -> None:
|
||||||
|
"""Handle unload."""
|
||||||
|
_LOGGER.debug("Integration unloading")
|
||||||
|
coordinator.client.stop()
|
||||||
|
await coordinator.client.async_cancel_tasks()
|
||||||
|
_LOGGER.debug("Integration closing connection")
|
||||||
|
await coordinator.client.close_connection()
|
||||||
|
_LOGGER.debug("Unloaded")
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> bool:
|
||||||
|
"""Set up the integration from a config entry."""
|
||||||
|
assert entry.unique_id
|
||||||
|
madVRClient = Madvr(
|
||||||
|
host=entry.data[CONF_HOST],
|
||||||
|
logger=_LOGGER,
|
||||||
|
port=entry.data[CONF_PORT],
|
||||||
|
mac=entry.unique_id,
|
||||||
|
connect_timeout=10,
|
||||||
|
loop=hass.loop,
|
||||||
|
)
|
||||||
|
coordinator = MadVRCoordinator(hass, madVRClient)
|
||||||
|
|
||||||
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
async def handle_unload(event: Event) -> None:
|
||||||
|
"""Handle unload."""
|
||||||
|
await async_handle_unload(coordinator=coordinator)
|
||||||
|
|
||||||
|
# listen for core stop event
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_unload)
|
||||||
|
|
||||||
|
# handle loading operations
|
||||||
|
await coordinator.handle_coordinator_load()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
if unload_ok:
|
||||||
|
coordinator: MadVRCoordinator = entry.runtime_data
|
||||||
|
await async_handle_unload(coordinator=coordinator)
|
||||||
|
|
||||||
|
return unload_ok
|
116
homeassistant/components/madvr/config_flow.py
Normal file
116
homeassistant/components/madvr/config_flow.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"""Config flow for the integration."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from madvr.madvr import HeartBeatError, Madvr
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
|
|
||||||
|
from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
|
||||||
|
from .errors import CannotConnect
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_HOST,
|
||||||
|
): str,
|
||||||
|
vol.Required(
|
||||||
|
CONF_PORT,
|
||||||
|
default=DEFAULT_PORT,
|
||||||
|
): int,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
RETRY_INTERVAL = 1
|
||||||
|
|
||||||
|
|
||||||
|
class MadVRConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for the integration."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
host = user_input[CONF_HOST]
|
||||||
|
port = user_input[CONF_PORT]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ensure we can connect and get the mac address from device
|
||||||
|
mac = await self._test_connection(host, port)
|
||||||
|
except CannotConnect:
|
||||||
|
_LOGGER.error("CannotConnect error caught")
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
else:
|
||||||
|
if not mac:
|
||||||
|
errors["base"] = "no_mac"
|
||||||
|
if not errors:
|
||||||
|
_LOGGER.debug("MAC address found: %s", mac)
|
||||||
|
# this will prevent the user from adding the same device twice and persist the mac address
|
||||||
|
await self.async_set_unique_id(mac)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
# create the entry
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=DEFAULT_NAME,
|
||||||
|
data=user_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
# this will show the form or allow the user to retry if there was an error
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
STEP_USER_DATA_SCHEMA, user_input
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _test_connection(self, host: str, port: int) -> str:
|
||||||
|
"""Test if we can connect to the device and grab the mac."""
|
||||||
|
madvr_client = Madvr(host=host, port=port, loop=self.hass.loop)
|
||||||
|
_LOGGER.debug("Testing connection to madVR at %s:%s", host, port)
|
||||||
|
# try to connect
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(madvr_client.open_connection(), timeout=15)
|
||||||
|
# connection can raise HeartBeatError if the device is not available or connection does not work
|
||||||
|
except (TimeoutError, aiohttp.ClientError, OSError, HeartBeatError) as err:
|
||||||
|
_LOGGER.error("Error connecting to madVR: %s", err)
|
||||||
|
raise CannotConnect from err
|
||||||
|
|
||||||
|
# check if we are connected
|
||||||
|
if not madvr_client.connected:
|
||||||
|
raise CannotConnect("Connection failed")
|
||||||
|
|
||||||
|
# background tasks needed to capture realtime info
|
||||||
|
await madvr_client.async_add_tasks()
|
||||||
|
|
||||||
|
# wait for client to capture device info
|
||||||
|
retry_time = 15
|
||||||
|
while not madvr_client.mac_address and retry_time > 0:
|
||||||
|
await asyncio.sleep(RETRY_INTERVAL)
|
||||||
|
retry_time -= 1
|
||||||
|
|
||||||
|
mac_address = madvr_client.mac_address
|
||||||
|
if mac_address:
|
||||||
|
_LOGGER.debug("Connected to madVR with MAC: %s", mac_address)
|
||||||
|
# close this connection because this client object will not be reused
|
||||||
|
await self._close_test_connection(madvr_client)
|
||||||
|
_LOGGER.debug("Connection test successful")
|
||||||
|
return mac_address
|
||||||
|
|
||||||
|
async def _close_test_connection(self, madvr_client: Madvr) -> None:
|
||||||
|
"""Close the test connection."""
|
||||||
|
madvr_client.stop()
|
||||||
|
await madvr_client.async_cancel_tasks()
|
||||||
|
await madvr_client.close_connection()
|
6
homeassistant/components/madvr/const.py
Normal file
6
homeassistant/components/madvr/const.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"""Constants for the madvr-envy integration."""
|
||||||
|
|
||||||
|
DOMAIN = "madvr"
|
||||||
|
|
||||||
|
DEFAULT_NAME = "envy"
|
||||||
|
DEFAULT_PORT = 44077
|
50
homeassistant/components/madvr/coordinator.py
Normal file
50
homeassistant/components/madvr/coordinator.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""Coordinator for handling data fetching and updates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from madvr.madvr import Madvr
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import MadVRConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
class MadVRCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
|
"""Madvr coordinator for Envy (push-based API)."""
|
||||||
|
|
||||||
|
config_entry: MadVRConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
client: Madvr,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize madvr coordinator."""
|
||||||
|
super().__init__(hass, _LOGGER, name=DOMAIN)
|
||||||
|
assert self.config_entry.unique_id
|
||||||
|
self.mac = self.config_entry.unique_id
|
||||||
|
self.client = client
|
||||||
|
self.client.set_update_callback(self.handle_push_data)
|
||||||
|
_LOGGER.debug("MadVRCoordinator initialized with mac: %s", self.mac)
|
||||||
|
|
||||||
|
def handle_push_data(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Handle new data pushed from the API."""
|
||||||
|
_LOGGER.debug("Received push data: %s", data)
|
||||||
|
# inform HA that we have new data
|
||||||
|
self.async_set_updated_data(data)
|
||||||
|
|
||||||
|
async def handle_coordinator_load(self) -> None:
|
||||||
|
"""Handle operations on integration load."""
|
||||||
|
_LOGGER.debug("Using loop: %s", self.client.loop)
|
||||||
|
# tell the library to start background tasks
|
||||||
|
await self.client.async_add_tasks()
|
||||||
|
_LOGGER.debug("Added %s tasks to client", len(self.client.tasks))
|
5
homeassistant/components/madvr/errors.py
Normal file
5
homeassistant/components/madvr/errors.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Errors for the madvr component."""
|
||||||
|
|
||||||
|
|
||||||
|
class CannotConnect(Exception):
|
||||||
|
"""Error to indicate we cannot connect."""
|
10
homeassistant/components/madvr/manifest.json
Normal file
10
homeassistant/components/madvr/manifest.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "madvr",
|
||||||
|
"name": "madVR Envy",
|
||||||
|
"codeowners": ["@iloveicedgreentea"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/madvr",
|
||||||
|
"integration_type": "device",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"requirements": ["py-madvr2==1.6.27"]
|
||||||
|
}
|
86
homeassistant/components/madvr/remote.py
Normal file
86
homeassistant/components/madvr/remote.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""Support for madVR remote control."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.remote import RemoteEntity
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from . import MadVRConfigEntry
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import MadVRCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: MadVRConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the madVR remote."""
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
[
|
||||||
|
MadvrRemote(coordinator),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MadvrRemote(CoordinatorEntity[MadVRCoordinator], RemoteEntity):
|
||||||
|
"""Remote entity for the madVR integration."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: MadVRCoordinator,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the remote entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.madvr_client = coordinator.client
|
||||||
|
self._attr_unique_id = coordinator.mac
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, coordinator.mac)},
|
||||||
|
name="madVR Envy",
|
||||||
|
manufacturer="madVR",
|
||||||
|
model="Envy",
|
||||||
|
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return true if the device is on."""
|
||||||
|
return self.madvr_client.is_on
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn off the device."""
|
||||||
|
_LOGGER.debug("Turning off")
|
||||||
|
try:
|
||||||
|
await self.madvr_client.power_off()
|
||||||
|
except (ConnectionError, NotImplementedError) as err:
|
||||||
|
_LOGGER.error("Failed to turn off device %s", err)
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn on the device."""
|
||||||
|
_LOGGER.debug("Turning on device")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.madvr_client.power_on(mac=self.coordinator.mac)
|
||||||
|
except (ConnectionError, NotImplementedError) as err:
|
||||||
|
_LOGGER.error("Failed to turn on device %s", err)
|
||||||
|
|
||||||
|
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||||
|
"""Send a command to one device."""
|
||||||
|
_LOGGER.debug("adding command %s", command)
|
||||||
|
try:
|
||||||
|
await self.madvr_client.add_command_to_queue(command)
|
||||||
|
except (ConnectionError, NotImplementedError) as err:
|
||||||
|
_LOGGER.error("Failed to send command %s", err)
|
25
homeassistant/components/madvr/strings.json
Normal file
25
homeassistant/components/madvr/strings.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Setup madVR Envy",
|
||||||
|
"description": "Your device needs to be turned in order to add the integation. ",
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
|
"port": "[%key:common::config_flow::data::port%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The hostname or IP address of your madVR Envy device.",
|
||||||
|
"port": "The port your madVR Envy is listening on. In 99% of cases, leave this as the default."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -322,6 +322,7 @@ FLOWS = {
|
|||||||
"lutron",
|
"lutron",
|
||||||
"lutron_caseta",
|
"lutron_caseta",
|
||||||
"lyric",
|
"lyric",
|
||||||
|
"madvr",
|
||||||
"mailgun",
|
"mailgun",
|
||||||
"matter",
|
"matter",
|
||||||
"mealie",
|
"mealie",
|
||||||
|
@ -3453,6 +3453,12 @@
|
|||||||
"integration_type": "virtual",
|
"integration_type": "virtual",
|
||||||
"supported_by": "motion_blinds"
|
"supported_by": "motion_blinds"
|
||||||
},
|
},
|
||||||
|
"madvr": {
|
||||||
|
"name": "madVR Envy",
|
||||||
|
"integration_type": "device",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
},
|
||||||
"mailgun": {
|
"mailgun": {
|
||||||
"name": "Mailgun",
|
"name": "Mailgun",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
10
mypy.ini
10
mypy.ini
@ -2633,6 +2633,16 @@ disallow_untyped_defs = true
|
|||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[mypy-homeassistant.components.madvr.*]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.mailbox.*]
|
[mypy-homeassistant.components.mailbox.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
@ -1637,6 +1637,9 @@ py-dormakaba-dkey==1.0.5
|
|||||||
# homeassistant.components.improv_ble
|
# homeassistant.components.improv_ble
|
||||||
py-improv-ble-client==1.0.3
|
py-improv-ble-client==1.0.3
|
||||||
|
|
||||||
|
# homeassistant.components.madvr
|
||||||
|
py-madvr2==1.6.27
|
||||||
|
|
||||||
# homeassistant.components.melissa
|
# homeassistant.components.melissa
|
||||||
py-melissa-climate==2.1.4
|
py-melissa-climate==2.1.4
|
||||||
|
|
||||||
|
@ -1311,6 +1311,9 @@ py-dormakaba-dkey==1.0.5
|
|||||||
# homeassistant.components.improv_ble
|
# homeassistant.components.improv_ble
|
||||||
py-improv-ble-client==1.0.3
|
py-improv-ble-client==1.0.3
|
||||||
|
|
||||||
|
# homeassistant.components.madvr
|
||||||
|
py-madvr2==1.6.27
|
||||||
|
|
||||||
# homeassistant.components.melissa
|
# homeassistant.components.melissa
|
||||||
py-melissa-climate==2.1.4
|
py-melissa-climate==2.1.4
|
||||||
|
|
||||||
|
13
tests/components/madvr/__init__.py
Normal file
13
tests/components/madvr/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Tests for the madvr-envy integration."""
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||||
|
"""Fixture for setting up the component."""
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
54
tests/components/madvr/conftest.py
Normal file
54
tests/components/madvr/conftest.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""MadVR conftest for shared testing setup."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.madvr.const import DEFAULT_NAME, DOMAIN
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
|
|
||||||
|
from .const import MOCK_CONFIG, MOCK_MAC
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Override async_setup_entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.madvr.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
yield mock_setup_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_madvr_client() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Mock a MadVR client."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.madvr.config_flow.Madvr", autospec=True
|
||||||
|
) as mock_client,
|
||||||
|
patch("homeassistant.components.madvr.Madvr", new=mock_client),
|
||||||
|
):
|
||||||
|
client = mock_client.return_value
|
||||||
|
client.host = MOCK_CONFIG[CONF_HOST]
|
||||||
|
client.port = MOCK_CONFIG[CONF_PORT]
|
||||||
|
client.mac_address = MOCK_MAC
|
||||||
|
client.connected.return_value = True
|
||||||
|
client.is_device_connectable.return_value = True
|
||||||
|
client.loop = AsyncMock()
|
||||||
|
client.tasks = AsyncMock()
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry() -> MockConfigEntry:
|
||||||
|
"""Mock a config entry."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=MOCK_CONFIG,
|
||||||
|
unique_id=MOCK_MAC,
|
||||||
|
title=DEFAULT_NAME,
|
||||||
|
)
|
10
tests/components/madvr/const.py
Normal file
10
tests/components/madvr/const.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""Constants for the MadVR tests."""
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
|
|
||||||
|
MOCK_CONFIG = {
|
||||||
|
CONF_HOST: "192.168.1.1",
|
||||||
|
CONF_PORT: 44077,
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_MAC = "00:11:22:33:44:55"
|
48
tests/components/madvr/snapshots/test_remote.ambr
Normal file
48
tests/components/madvr/snapshots/test_remote.ambr
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_remote_setup[remote.madvr_envy-entry]
|
||||||
|
EntityRegistryEntrySnapshot({
|
||||||
|
'aliases': set({
|
||||||
|
}),
|
||||||
|
'area_id': None,
|
||||||
|
'capabilities': None,
|
||||||
|
'config_entry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'disabled_by': None,
|
||||||
|
'domain': 'remote',
|
||||||
|
'entity_category': None,
|
||||||
|
'entity_id': 'remote.madvr_envy',
|
||||||
|
'has_entity_name': True,
|
||||||
|
'hidden_by': None,
|
||||||
|
'icon': None,
|
||||||
|
'id': <ANY>,
|
||||||
|
'labels': set({
|
||||||
|
}),
|
||||||
|
'name': None,
|
||||||
|
'options': dict({
|
||||||
|
}),
|
||||||
|
'original_device_class': None,
|
||||||
|
'original_icon': None,
|
||||||
|
'original_name': None,
|
||||||
|
'platform': 'madvr',
|
||||||
|
'previous_unique_id': None,
|
||||||
|
'supported_features': 0,
|
||||||
|
'translation_key': None,
|
||||||
|
'unique_id': '00:11:22:33:44:55',
|
||||||
|
'unit_of_measurement': None,
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_remote_setup[remote.madvr_envy-state]
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'madVR Envy',
|
||||||
|
'supported_features': <RemoteEntityFeature: 0>,
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'remote.madvr_envy',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'off',
|
||||||
|
})
|
||||||
|
# ---
|
128
tests/components/madvr/test_config_flow.py
Normal file
128
tests/components/madvr/test_config_flow.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"""Tests for the MadVR config flow."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.madvr.const import DEFAULT_NAME, DOMAIN
|
||||||
|
from homeassistant.config_entries import SOURCE_USER
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from .const import MOCK_CONFIG, MOCK_MAC
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def avoid_wait() -> AsyncGenerator[None, None]:
|
||||||
|
"""Mock sleep."""
|
||||||
|
with patch("homeassistant.components.madvr.config_flow.RETRY_INTERVAL", 0):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_madvr_client: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test full config flow."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: MOCK_CONFIG[CONF_HOST], CONF_PORT: MOCK_CONFIG[CONF_PORT]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_HOST: MOCK_CONFIG[CONF_HOST],
|
||||||
|
CONF_PORT: MOCK_CONFIG[CONF_PORT],
|
||||||
|
}
|
||||||
|
assert result["result"].unique_id == MOCK_MAC
|
||||||
|
mock_madvr_client.open_connection.assert_called_once()
|
||||||
|
mock_madvr_client.async_add_tasks.assert_called_once()
|
||||||
|
mock_madvr_client.async_cancel_tasks.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_madvr_client: AsyncMock,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test error handling in config flow."""
|
||||||
|
mock_madvr_client.open_connection.side_effect = TimeoutError
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: MOCK_CONFIG[CONF_HOST], CONF_PORT: MOCK_CONFIG[CONF_PORT]},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
mock_madvr_client.open_connection.side_effect = None
|
||||||
|
mock_madvr_client.connected = False
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: MOCK_CONFIG[CONF_HOST], CONF_PORT: MOCK_CONFIG[CONF_PORT]},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
mock_madvr_client.connected = True
|
||||||
|
mock_madvr_client.mac_address = None
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: MOCK_CONFIG[CONF_HOST], CONF_PORT: MOCK_CONFIG[CONF_PORT]},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": "no_mac"}
|
||||||
|
|
||||||
|
# ensure an error is recoverable
|
||||||
|
mock_madvr_client.mac_address = MOCK_MAC
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: MOCK_CONFIG[CONF_HOST], CONF_PORT: MOCK_CONFIG[CONF_PORT]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == DEFAULT_NAME
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_HOST: MOCK_CONFIG[CONF_HOST],
|
||||||
|
CONF_PORT: MOCK_CONFIG[CONF_PORT],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify method calls
|
||||||
|
assert mock_madvr_client.open_connection.call_count == 4
|
||||||
|
assert mock_madvr_client.async_add_tasks.call_count == 2
|
||||||
|
# the first call will not call this due to timeout as expected
|
||||||
|
assert mock_madvr_client.async_cancel_tasks.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_madvr_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test duplicate config entries."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{CONF_HOST: MOCK_CONFIG[CONF_HOST], CONF_PORT: MOCK_CONFIG[CONF_PORT]},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
28
tests/components/madvr/test_init.py
Normal file
28
tests/components/madvr/test_init.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""Tests for the MadVR integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from . import setup_integration
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_load_unload_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_madvr_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test load and unload entry."""
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
await hass.config_entries.async_remove(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
85
tests/components/madvr/test_remote.py
Normal file
85
tests/components/madvr/test_remote.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for the MadVR remote entity."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from syrupy import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_ENTITY_ID,
|
||||||
|
SERVICE_TURN_OFF,
|
||||||
|
SERVICE_TURN_ON,
|
||||||
|
STATE_ON,
|
||||||
|
Platform,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
import homeassistant.helpers.entity_registry as er
|
||||||
|
|
||||||
|
from . import setup_integration
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, snapshot_platform
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remote_setup(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test setup of the remote entity."""
|
||||||
|
with patch("homeassistant.components.madvr.PLATFORMS", [Platform.REMOTE]):
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_remote_power(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_madvr_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test turning on the remote entity."""
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
entity_id = "remote.madvr_envy"
|
||||||
|
remote = hass.states.get(entity_id)
|
||||||
|
assert remote.state == STATE_ON
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
REMOTE_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_madvr_client.power_off.assert_called_once()
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
REMOTE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
mock_madvr_client.power_on.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_command(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_madvr_client: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test sending command to the remote entity."""
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
entity_id = "remote.madvr_envy"
|
||||||
|
remote = hass.states.get(entity_id)
|
||||||
|
assert remote.state == STATE_ON
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
REMOTE_DOMAIN,
|
||||||
|
"send_command",
|
||||||
|
{ATTR_ENTITY_ID: entity_id, "command": "test"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_madvr_client.add_command_to_queue.assert_called_once_with(["test"])
|
Loading…
x
Reference in New Issue
Block a user