diff --git a/.strict-typing b/.strict-typing index a6deb6eca3a..7d6bd1286af 100644 --- a/.strict-typing +++ b/.strict-typing @@ -287,6 +287,7 @@ homeassistant.components.logger.* homeassistant.components.london_underground.* homeassistant.components.lookin.* homeassistant.components.luftdaten.* +homeassistant.components.madvr.* homeassistant.components.mailbox.* homeassistant.components.map.* homeassistant.components.mastodon.* diff --git a/CODEOWNERS b/CODEOWNERS index 1dc4268b54a..64b9daf9560 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -827,6 +827,8 @@ build.json @home-assistant/supervisor /tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151 /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 +/homeassistant/components/madvr/ @iloveicedgreentea +/tests/components/madvr/ @iloveicedgreentea /homeassistant/components/mastodon/ @fabaff /homeassistant/components/matrix/ @PaarthShah /tests/components/matrix/ @PaarthShah diff --git a/homeassistant/components/madvr/__init__.py b/homeassistant/components/madvr/__init__.py new file mode 100644 index 00000000000..df9ffce6d95 --- /dev/null +++ b/homeassistant/components/madvr/__init__.py @@ -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 diff --git a/homeassistant/components/madvr/config_flow.py b/homeassistant/components/madvr/config_flow.py new file mode 100644 index 00000000000..cf43e03a68b --- /dev/null +++ b/homeassistant/components/madvr/config_flow.py @@ -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() diff --git a/homeassistant/components/madvr/const.py b/homeassistant/components/madvr/const.py new file mode 100644 index 00000000000..f0adeb9b6a5 --- /dev/null +++ b/homeassistant/components/madvr/const.py @@ -0,0 +1,6 @@ +"""Constants for the madvr-envy integration.""" + +DOMAIN = "madvr" + +DEFAULT_NAME = "envy" +DEFAULT_PORT = 44077 diff --git a/homeassistant/components/madvr/coordinator.py b/homeassistant/components/madvr/coordinator.py new file mode 100644 index 00000000000..fb6dbb0d8b2 --- /dev/null +++ b/homeassistant/components/madvr/coordinator.py @@ -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)) diff --git a/homeassistant/components/madvr/errors.py b/homeassistant/components/madvr/errors.py new file mode 100644 index 00000000000..67215760248 --- /dev/null +++ b/homeassistant/components/madvr/errors.py @@ -0,0 +1,5 @@ +"""Errors for the madvr component.""" + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/madvr/manifest.json b/homeassistant/components/madvr/manifest.json new file mode 100644 index 00000000000..9aa2c5a9b5d --- /dev/null +++ b/homeassistant/components/madvr/manifest.json @@ -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"] +} diff --git a/homeassistant/components/madvr/remote.py b/homeassistant/components/madvr/remote.py new file mode 100644 index 00000000000..7477888d342 --- /dev/null +++ b/homeassistant/components/madvr/remote.py @@ -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) diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json new file mode 100644 index 00000000000..a51eb093e7b --- /dev/null +++ b/homeassistant/components/madvr/strings.json @@ -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." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 92b530f7322..0ce3282c272 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -322,6 +322,7 @@ FLOWS = { "lutron", "lutron_caseta", "lyric", + "madvr", "mailgun", "matter", "mealie", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8751bbe0b40..d7fc52f2ccb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3453,6 +3453,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "madvr": { + "name": "madVR Envy", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "mailgun": { "name": "Mailgun", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index d94e5a37194..b24898b3287 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2633,6 +2633,16 @@ disallow_untyped_defs = true warn_return_any = 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.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 20bd93a0c9e..e069c4636e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1637,6 +1637,9 @@ py-dormakaba-dkey==1.0.5 # homeassistant.components.improv_ble py-improv-ble-client==1.0.3 +# homeassistant.components.madvr +py-madvr2==1.6.27 + # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f194abc18c..83da9ce7ed8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1311,6 +1311,9 @@ py-dormakaba-dkey==1.0.5 # homeassistant.components.improv_ble py-improv-ble-client==1.0.3 +# homeassistant.components.madvr +py-madvr2==1.6.27 + # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/tests/components/madvr/__init__.py b/tests/components/madvr/__init__.py new file mode 100644 index 00000000000..343dd68a25d --- /dev/null +++ b/tests/components/madvr/__init__.py @@ -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() diff --git a/tests/components/madvr/conftest.py b/tests/components/madvr/conftest.py new file mode 100644 index 00000000000..10da7ba0982 --- /dev/null +++ b/tests/components/madvr/conftest.py @@ -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, + ) diff --git a/tests/components/madvr/const.py b/tests/components/madvr/const.py new file mode 100644 index 00000000000..6bfa3a77167 --- /dev/null +++ b/tests/components/madvr/const.py @@ -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" diff --git a/tests/components/madvr/snapshots/test_remote.ambr b/tests/components/madvr/snapshots/test_remote.ambr new file mode 100644 index 00000000000..1157496a93e --- /dev/null +++ b/tests/components/madvr/snapshots/test_remote.ambr @@ -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': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'remote', + 'entity_category': None, + 'entity_id': 'remote.madvr_envy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + }), + 'context': , + 'entity_id': 'remote.madvr_envy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/madvr/test_config_flow.py b/tests/components/madvr/test_config_flow.py new file mode 100644 index 00000000000..b3edbf25fb1 --- /dev/null +++ b/tests/components/madvr/test_config_flow.py @@ -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" diff --git a/tests/components/madvr/test_init.py b/tests/components/madvr/test_init.py new file mode 100644 index 00000000000..dace812af11 --- /dev/null +++ b/tests/components/madvr/test_init.py @@ -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 diff --git a/tests/components/madvr/test_remote.py b/tests/components/madvr/test_remote.py new file mode 100644 index 00000000000..fc6471bf664 --- /dev/null +++ b/tests/components/madvr/test_remote.py @@ -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"])