diff --git a/.coveragerc b/.coveragerc index adb0458d6f6..7e1a3287a19 100644 --- a/.coveragerc +++ b/.coveragerc @@ -757,6 +757,9 @@ omit = homeassistant/components/motion_blinds/cover.py homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py + homeassistant/components/motionmount/__init__.py + homeassistant/components/motionmount/entity.py + homeassistant/components/motionmount/number.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py diff --git a/.strict-typing b/.strict-typing index 01b88ec2781..d23da1c2fd2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -238,6 +238,7 @@ homeassistant.components.modbus.* homeassistant.components.modem_callerid.* homeassistant.components.moon.* homeassistant.components.mopeka.* +homeassistant.components.motionmount.* homeassistant.components.mqtt.* homeassistant.components.mysensors.* homeassistant.components.nam.* diff --git a/CODEOWNERS b/CODEOWNERS index 1ed96218424..05edd2c2b84 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -809,6 +809,8 @@ build.json @home-assistant/supervisor /tests/components/motion_blinds/ @starkillerOG /homeassistant/components/motioneye/ @dermotduffy /tests/components/motioneye/ @dermotduffy +/homeassistant/components/motionmount/ @RJPoelstra +/tests/components/motionmount/ @RJPoelstra /homeassistant/components/mqtt/ @emontnemery @jbouwh /tests/components/mqtt/ @emontnemery @jbouwh /homeassistant/components/msteams/ @peroyvind diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py new file mode 100644 index 00000000000..8baceb104c3 --- /dev/null +++ b/homeassistant/components/motionmount/__init__.py @@ -0,0 +1,61 @@ +"""The Vogel's MotionMount integration.""" +from __future__ import annotations + +import socket + +import motionmount + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, EMPTY_MAC + +PLATFORMS: list[Platform] = [ + Platform.NUMBER, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Vogel's MotionMount from a config entry.""" + + host = entry.data[CONF_HOST] + + # Create API instance + mm = motionmount.MotionMount(host, entry.data[CONF_PORT]) + + # Validate the API connection + try: + await mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror) as ex: + raise ConfigEntryNotReady(f"Failed to connect to {host}") from ex + + found_mac = format_mac(mm.mac.hex()) + if found_mac not in (EMPTY_MAC, entry.unique_id): + # If the mac address of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + await mm.disconnect() + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + ) + + # Store an API object for your platforms to access + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id) + await mm.disconnect() + + return unload_ok diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py new file mode 100644 index 00000000000..a593b30201e --- /dev/null +++ b/homeassistant/components/motionmount/config_flow.py @@ -0,0 +1,176 @@ +"""Config flow for Vogel's MotionMount.""" +import logging +import socket +from typing import Any + +import motionmount +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, EMPTY_MAC + +_LOGGER = logging.getLogger(__name__) + + +# A MotionMount can be in four states: +# 1. Old CE and old Pro FW -> It doesn't supply any kind of mac +# 2. Old CE but new Pro FW -> It supplies its mac using DNS-SD, but a read of the mac fails +# 3. New CE but old Pro FW -> It doesn't supply the mac using DNS-SD but we can read it (returning the EMPTY_MAC) +# 4. New CE and new Pro FW -> Both DNS-SD and a read gives us the mac +# If we can't get the mac, we use DEFAULT_DISCOVERY_UNIQUE_ID as an ID, so we can always configure a single MotionMount. Most households will only have a single MotionMount +class MotionMountFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Vogel's MotionMount config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Set up the instance.""" + self.discovery_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form() + + info = {} + try: + info = await self._validate_input(user_input) + except (ConnectionError, socket.gaierror): + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="time_out") + except motionmount.NotConnectedError: + return self.async_abort(reason="not_connected") + except motionmount.MotionMountResponseError: + # This is most likely due to missing support for the mac address property + # Abort if the handler has config entries already + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + # Otherwise we try to continue with the generic uid + info[CONF_UUID] = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + + # If the device mac is valid we use it, otherwise we use the default id + if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: + unique_id = info[CONF_UUID] + else: + unique_id = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + + name = info.get(CONF_NAME, user_input[CONF_HOST]) + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + return self.async_create_entry(title=name, data=user_input) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + + # Extract information from discovery + host = discovery_info.hostname + port = discovery_info.port + zctype = discovery_info.type + name = discovery_info.name.removesuffix(f".{zctype}") + unique_id = discovery_info.properties.get("mac") + + self.discovery_info.update( + { + CONF_HOST: host, + CONF_PORT: port, + CONF_NAME: name, + } + ) + + if unique_id: + # If we already have the unique id, try to set it now + # so we can avoid probing the device if its already + # configured or ignored + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + else: + # Avoid probing devices that already have an entry + self._async_abort_entries_match({CONF_HOST: host}) + + self.context.update({"title_placeholders": {"name": name}}) + + try: + info = await self._validate_input(self.discovery_info) + except (ConnectionError, socket.gaierror): + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="time_out") + except motionmount.NotConnectedError: + return self.async_abort(reason="not_connected") + except motionmount.MotionMountResponseError: + info = {} + # We continue as we want to be able to connect with older FW that does not support MAC address + + # If the device supplied as with a valid MAC we use that + if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: + unique_id = info[CONF_UUID] + + if unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + else: + await self._async_handle_discovery_without_unique_id() + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a confirmation flow initiated by zeroconf.""" + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]}, + errors={}, + ) + + return self.async_create_entry( + title=self.discovery_info[CONF_NAME], + data=self.discovery_info, + ) + + async def _validate_input(self, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) + try: + await mm.connect() + finally: + await mm.disconnect() + + return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name} + + def _show_setup_form(self, errors: dict[str, str] | None = None) -> FlowResult: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=23): int, + } + ), + errors=errors or {}, + ) diff --git a/homeassistant/components/motionmount/const.py b/homeassistant/components/motionmount/const.py new file mode 100644 index 00000000000..92045193ad6 --- /dev/null +++ b/homeassistant/components/motionmount/const.py @@ -0,0 +1,5 @@ +"""Constants for the Vogel's MotionMount integration.""" + +DOMAIN = "motionmount" + +EMPTY_MAC = "00:00:00:00:00:00" diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py new file mode 100644 index 00000000000..c3f7c9c9358 --- /dev/null +++ b/homeassistant/components/motionmount/entity.py @@ -0,0 +1,53 @@ +"""Support for MotionMount sensors.""" + +import motionmount + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, EMPTY_MAC + + +class MotionMountEntity(Entity): + """Representation of a MotionMount entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize general MotionMount entity.""" + self.mm = mm + mac = format_mac(mm.mac.hex()) + + # Create a base unique id + if mac == EMPTY_MAC: + self._base_unique_id = config_entry.entry_id + else: + self._base_unique_id = mac + + # Set device info + self._attr_device_info = DeviceInfo( + name=mm.name, + manufacturer="Vogel's", + model="TVM 7675", + ) + + if mac == EMPTY_MAC: + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, config_entry.entry_id)} + else: + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, mac) + } + + async def async_added_to_hass(self) -> None: + """Store register state change callback.""" + self.mm.add_listener(self.async_write_ha_state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Remove register state change callback.""" + self.mm.remove_listener(self.async_write_ha_state) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json new file mode 100644 index 00000000000..bfe7e21fce9 --- /dev/null +++ b/homeassistant/components/motionmount/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "motionmount", + "name": "Vogel's MotionMount", + "codeowners": ["@RJPoelstra"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/motionmount", + "integration_type": "device", + "iot_class": "local_push", + "requirements": ["python-MotionMount==0.3.1"], + "zeroconf": ["_tvm._tcp.local."] +} diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py new file mode 100644 index 00000000000..476e14c3a82 --- /dev/null +++ b/homeassistant/components/motionmount/number.py @@ -0,0 +1,71 @@ +"""Support for MotionMount numeric control.""" +import motionmount + +from homeassistant.components.number import NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import MotionMountEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Vogel's MotionMount from a config entry.""" + mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ( + MotionMountExtension(mm, entry), + MotionMountTurn(mm, entry), + ) + ) + + +class MotionMountExtension(MotionMountEntity, NumberEntity): + """The target extension position of a MotionMount.""" + + _attr_native_max_value = 100 + _attr_native_min_value = 0 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_translation_key = "motionmount_extension" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize Extension number.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-extension" + + @property + def native_value(self) -> float: + """Get native value.""" + return float(self.mm.extension or 0) + + async def async_set_native_value(self, value: float) -> None: + """Set the new value for extension.""" + await self.mm.set_extension(int(value)) + + +class MotionMountTurn(MotionMountEntity, NumberEntity): + """The target turn position of a MotionMount.""" + + _attr_native_max_value = 100 + _attr_native_min_value = -100 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_translation_key = "motionmount_turn" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize Turn number.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-turn" + + @property + def native_value(self) -> float: + """Get native value.""" + return float(self.mm.turn or 0) * -1 + + async def async_set_native_value(self, value: float) -> None: + """Set the new value for turn.""" + await self.mm.set_turn(int(value * -1)) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json new file mode 100644 index 00000000000..00a409f3058 --- /dev/null +++ b/homeassistant/components/motionmount/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Link your MotionMount", + "description": "Set up your MotionMount to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "zeroconf_confirm": { + "description": "Do you want to set up {name}?", + "title": "Discovered MotionMount" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "time_out": "Failed to connect due to a time out.", + "not_connected": "Failed to connect.", + "invalid_response": "Failed to connect due to an invalid response from the MotionMount." + } + }, + "entity": { + "number": { + "motionmount_extension": { + "name": "Extension" + }, + "motionmount_turn": { + "name": "Turn" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index df69af184ac..47f7087fcc8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -304,6 +304,7 @@ FLOWS = { "mopeka", "motion_blinds", "motioneye", + "motionmount", "mqtt", "mullvad", "mutesync", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0a3229d73b2..db385c5eff9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3613,6 +3613,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "motionmount": { + "name": "Vogel's MotionMount", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "mpd": { "name": "Music Player Daemon (MPD)", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 78f21f90b5e..fea1d4ec889 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -705,6 +705,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_tvm._tcp.local.": [ + { + "domain": "motionmount", + }, + ], "_uzg-01._tcp.local.": [ { "domain": "zha", diff --git a/mypy.ini b/mypy.ini index bd0e4f76b85..45ad5207078 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2141,6 +2141,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.motionmount.*] +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.mqtt.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 4ba4b8ab03e..bcf8a8bd6a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2132,6 +2132,9 @@ pytfiac==0.4 # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 +# homeassistant.components.motionmount +python-MotionMount==0.3.1 + # homeassistant.components.awair python-awair==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9623f502472..739cc1db31b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1622,6 +1622,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.motionmount +python-MotionMount==0.3.1 + # homeassistant.components.awair python-awair==0.2.4 diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py new file mode 100644 index 00000000000..da6fbae32a3 --- /dev/null +++ b/tests/components/motionmount/__init__.py @@ -0,0 +1,42 @@ +"""Tests for the Vogel's MotionMount integration.""" + +from ipaddress import ip_address + +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_PORT + +HOST = "192.168.1.31" +PORT = 23 + +TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." + +ZEROCONF_NAME = "My MotionMount" +ZEROCONF_HOST = HOST +ZEROCONF_HOSTNAME = "MMF8A55F.local." +ZEROCONF_PORT = PORT +ZEROCONF_MAC = "c4:dd:57:f8:a5:5f" + +MOCK_USER_INPUT = { + CONF_HOST: HOST, + CONF_PORT: PORT, +} + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( + type=TVM_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"txtvers": "1", "model": "TVM 7675"}, +) + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo( + type=TVM_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"mac": ZEROCONF_MAC, "txtvers": "2", "model": "TVM 7675"}, +) diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py new file mode 100644 index 00000000000..8a838dac83c --- /dev/null +++ b/tests/components/motionmount/conftest.py @@ -0,0 +1,44 @@ +"""Fixtures for Vogel's MotionMount integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.motionmount.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=ZEROCONF_MAC, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.motionmount.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_motionmount_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked MotionMount config flow.""" + + with patch( + "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + autospec=True, + ) as motionmount_mock: + client = motionmount_mock.return_value + yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py new file mode 100644 index 00000000000..aa7ea73b577 --- /dev/null +++ b/tests/components/motionmount/test_config_flow.py @@ -0,0 +1,488 @@ +"""Tests for the Vogel's MotionMount config flow.""" +import dataclasses +import socket +from unittest.mock import MagicMock, PropertyMock + +import motionmount +import pytest + +from homeassistant.components.motionmount.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + HOST, + MOCK_USER_INPUT, + MOCK_ZEROCONF_TVM_SERVICE_INFO_V1, + MOCK_ZEROCONF_TVM_SERVICE_INFO_V2, + PORT, + ZEROCONF_HOSTNAME, + ZEROCONF_MAC, + ZEROCONF_NAME, +) + +from tests.common import MockConfigEntry + +MAC = bytes.fromhex("c4dd57f8a55f") +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + +async def test_user_connection_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_connection_error_invalid_hostname( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when an invalid hostname is provided.""" + mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_timeout_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a timeout error.""" + mock_motionmount_config_flow.connect.side_effect = TimeoutError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "time_out" + + +async def test_user_not_connected_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a not connected error.""" + mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_connected" + + +async def test_user_response_error_single_device_old_ce_old_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + + +async def test_user_response_error_single_device_new_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock( + return_value=b"\x00\x00\x00\x00\x00\x00" + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + + +async def test_user_response_error_single_device_new_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_user_response_error_multi_device_old_ce_old_new_pro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there are multiple devices.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_response_error_multi_device_new_ce_new_pro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there are multiple devices.""" + mock_config_entry.add_to_hass(hass) + + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_connection_error_invalid_hostname( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_timout_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a timeout error.""" + mock_motionmount_config_flow.connect.side_effect = TimeoutError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "time_out" + + +async def test_zeroconf_not_connected_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a not connected error.""" + mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_connected" + + +async def test_show_zeroconf_form_old_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_old_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_new_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + type(mock_motionmount_config_flow).mac = PropertyMock( + return_value=b"\x00\x00\x00\x00\x00\x00" + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_new_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_zeroconf_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test we abort zeroconf flow if device already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test the full manual user flow from start to finish.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_full_zeroconf_flow_implementation( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test the full manual user flow from start to finish.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC