diff --git a/CODEOWNERS b/CODEOWNERS index 490f97879a4..752bbb31460 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1796,6 +1796,8 @@ build.json @home-assistant/supervisor /tests/components/zeversolar/ @kvanzuijlen /homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES +/homeassistant/components/zimi/ @markhannon +/tests/components/zimi/ @markhannon /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core diff --git a/homeassistant/components/zimi/__init__.py b/homeassistant/components/zimi/__init__.py new file mode 100644 index 00000000000..db91f7816c4 --- /dev/null +++ b/homeassistant/components/zimi/__init__.py @@ -0,0 +1,67 @@ +"""The zcc integration.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint, ControlPointError + +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 import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import DOMAIN +from .helpers import async_connect_to_controller + +PLATFORMS = [Platform.LIGHT] + +_LOGGER = logging.getLogger(__name__) + + +type ZimiConfigEntry = ConfigEntry[ControlPoint] + + +async def async_setup_entry(hass: HomeAssistant, entry: ZimiConfigEntry) -> bool: + """Connect to Zimi Controller and register device.""" + + try: + api = await async_connect_to_controller( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ) + + except ControlPointError as error: + raise ConfigEntryNotReady(f"Zimi setup failed: {error}") from error + + _LOGGER.debug("\n%s", api.describe()) + + entry.runtime_data = api + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, api.mac)}, + manufacturer=api.brand, + name=f"{api.network_name}", + model="Zimi Cloud Connect", + sw_version=api.firmware_version, + connections={(CONNECTION_NETWORK_MAC, api.mac)}, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + _LOGGER.debug("Zimi setup complete") + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ZimiConfigEntry) -> bool: + """Unload a config entry.""" + + api = entry.runtime_data + api.disconnect() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/zimi/config_flow.py b/homeassistant/components/zimi/config_flow.py new file mode 100644 index 00000000000..1037a05a2ce --- /dev/null +++ b/homeassistant/components/zimi/config_flow.py @@ -0,0 +1,172 @@ +"""Config flow for zcc integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from zcc import ( + ControlPoint, + ControlPointCannotConnectError, + ControlPointConnectionRefusedError, + ControlPointDescription, + ControlPointDiscoveryService, + ControlPointError, + ControlPointInvalidHostError, + ControlPointTimeoutError, +) + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +DEFAULT_PORT = 5003 +STEP_MANUAL_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + +SELECTED_HOST_AND_PORT = "selected_host_and_port" + + +class ZimiConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for zcc.""" + + api: ControlPoint = None + api_descriptions: list[ControlPointDescription] + data: dict[str, Any] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial auto-discovery step.""" + + self.data = {} + + try: + self.api_descriptions = await ControlPointDiscoveryService().discovers() + except ControlPointError: + # ControlPointError is expected if no zcc are found on LAN + return await self.async_step_manual() + + if len(self.api_descriptions) == 1: + self.data[CONF_HOST] = self.api_descriptions[0].host + self.data[CONF_PORT] = self.api_descriptions[0].port + await self.check_connection(self.data[CONF_HOST], self.data[CONF_PORT]) + return await self.create_entry() + + return await self.async_step_selection() + + async def async_step_selection( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selection of zcc to configure if multiple are discovered.""" + + errors: dict[str, str] | None = {} + + if user_input is not None: + self.data[CONF_HOST] = user_input[SELECTED_HOST_AND_PORT].split(":")[0] + self.data[CONF_PORT] = int(user_input[SELECTED_HOST_AND_PORT].split(":")[1]) + errors = await self.check_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + if not errors: + return await self.create_entry() + + available_options = [ + SelectOptionDict( + label=f"{description.host}:{description.port}", + value=f"{description.host}:{description.port}", + ) + for description in self.api_descriptions + ] + + available_schema = vol.Schema( + { + vol.Required( + SELECTED_HOST_AND_PORT, default=available_options[0]["value"] + ): SelectSelector( + SelectSelectorConfig( + options=available_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ) + + return self.async_show_form( + step_id="selection", data_schema=available_schema, errors=errors + ) + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle manual configuration step if needed.""" + + errors: dict[str, str] | None = {} + + if user_input is not None: + self.data = {**self.data, **user_input} + + errors = await self.check_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + + if not errors: + return await self.create_entry() + + return self.async_show_form( + step_id="manual", + data_schema=self.add_suggested_values_to_schema( + STEP_MANUAL_DATA_SCHEMA, self.data + ), + errors=errors, + ) + + async def check_connection(self, host: str, port: int) -> dict[str, str] | None: + """Check connection to zcc. + + Stores mac and returns None if successful, otherwise returns error message. + """ + + try: + result = await ControlPointDiscoveryService().validate_connection( + self.data[CONF_HOST], self.data[CONF_PORT] + ) + except ControlPointInvalidHostError: + return {"base": "invalid_host"} + except ControlPointConnectionRefusedError: + return {"base": "connection_refused"} + except ControlPointCannotConnectError: + return {"base": "cannot_connect"} + except ControlPointTimeoutError: + return {"base": "timeout"} + except Exception: + _LOGGER.exception("Unexpected error") + return {"base": "unknown"} + + self.data[CONF_MAC] = format_mac(result.mac) + + return None + + async def create_entry(self) -> ConfigFlowResult: + """Create entry for zcc.""" + + await self.async_set_unique_id(self.data[CONF_MAC]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"ZIMI Controller ({self.data[CONF_HOST]}:{self.data[CONF_PORT]})", + data=self.data, + ) diff --git a/homeassistant/components/zimi/const.py b/homeassistant/components/zimi/const.py new file mode 100644 index 00000000000..1a426875b75 --- /dev/null +++ b/homeassistant/components/zimi/const.py @@ -0,0 +1,3 @@ +"""Constants for the zcc integration.""" + +DOMAIN = "zimi" diff --git a/homeassistant/components/zimi/entity.py b/homeassistant/components/zimi/entity.py new file mode 100644 index 00000000000..64781454b2c --- /dev/null +++ b/homeassistant/components/zimi/entity.py @@ -0,0 +1,66 @@ +"""Base entity for zimi integrations.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint +from zcc.device import ControlPointDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ZimiEntity(Entity): + """Representation of a Zimi API entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + """Initialize an HA Entity which is a ZimiDevice.""" + + self._device = device + self._attr_unique_id = self._device.identifier + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device.manufacture_info.identifier)}, + manufacturer=self._device.manufacture_info.manufacturer, + model=self._device.manufacture_info.model, + name=self._device.manufacture_info.name, + hw_version=device.manufacture_info.hwVersion, + sw_version=device.manufacture_info.firmwareVersion, + suggested_area=device.room, + via_device=(DOMAIN, api.mac), + ) + self._attr_name = self._device.name.strip() + self._attr_suggested_area = self._device.room + + @property + def available(self) -> bool: + """Return True if Home Assistant is able to read the state and control the underlying device.""" + return self._device.is_connected + + async def async_added_to_hass(self) -> None: + """Subscribe to the events.""" + await super().async_added_to_hass() + self._device.subscribe(self) + + async def async_will_remove_from_hass(self) -> None: + """Cleanup ZimiLight with removal of notification prior to removal.""" + self._device.unsubscribe(self) + await super().async_will_remove_from_hass() + + def notify(self, _observable: object) -> None: + """Receive notification from device that state has changed. + + No data is fetched for the notification but schedule_update_ha_state is called. + """ + + _LOGGER.debug( + "Received notification() for %s in %s", self._device.name, self._device.room + ) + self.schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/zimi/helpers.py b/homeassistant/components/zimi/helpers.py new file mode 100644 index 00000000000..81d9a986f46 --- /dev/null +++ b/homeassistant/components/zimi/helpers.py @@ -0,0 +1,38 @@ +"""The zcc integration helpers.""" + +from __future__ import annotations + +import logging + +from zcc import ControlPoint, ControlPointDescription + +from homeassistant.exceptions import ConfigEntryNotReady + +_LOGGER = logging.getLogger(__name__) + + +async def async_connect_to_controller( + host: str, port: int, fast: bool = False +) -> ControlPoint: + """Connect to Zimi Cloud Controller with defined parameters.""" + + _LOGGER.debug("Connecting to %s:%d", host, port) + + api = ControlPoint( + description=ControlPointDescription( + host=host, + port=port, + ) + ) + await api.connect(fast=fast) + + if api.ready: + _LOGGER.debug("Connected") + + if not fast: + api.start_watchdog() + _LOGGER.debug("Started watchdog") + + return api + + raise ConfigEntryNotReady("Connection failed: not ready") diff --git a/homeassistant/components/zimi/light.py b/homeassistant/components/zimi/light.py new file mode 100644 index 00000000000..a93bbb53b3d --- /dev/null +++ b/homeassistant/components/zimi/light.py @@ -0,0 +1,103 @@ +"""Light platform for zcc integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from zcc import ControlPoint +from zcc.device import ControlPointDevice + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ZimiConfigEntry +from .entity import ZimiEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ZimiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Zimi Light platform.""" + + api = config_entry.runtime_data + + lights: list[ZimiLight | ZimiDimmer] = [ + ZimiLight(device, api) for device in api.lights if device.type != "dimmer" + ] + + lights.extend( + [ZimiDimmer(device, api) for device in api.lights if device.type == "dimmer"] + ) + + async_add_entities(lights) + + +class ZimiLight(ZimiEntity, LightEntity): + """Representation of a Zimi Light.""" + + def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + """Initialize a ZimiLight.""" + + super().__init__(device, api) + + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on (with optional brightness).""" + + _LOGGER.debug( + "Sending turn_on() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + + _LOGGER.debug( + "Sending turn_off() for %s in %s", self._device.name, self._device.room + ) + + await self._device.turn_off() + + +class ZimiDimmer(ZimiLight): + """Zimi Light supporting dimming.""" + + def __init__(self, device: ControlPointDevice, api: ControlPoint) -> None: + """Initialize a ZimiDimmer.""" + super().__init__(device, api) + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + if self._device.type != "dimmer": + raise ValueError("ZimiDimmer needs a dimmable light") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on (with optional brightness).""" + + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) * 100 / 255 + _LOGGER.debug( + "Sending turn_on(brightness=%d) for %s in %s", + brightness, + self._device.name, + self._device.room, + ) + + await self._device.set_brightness(brightness) + + @property + def brightness(self) -> int | None: + """Return the brightness of the light.""" + return round(self._device.brightness * 255 / 100) diff --git a/homeassistant/components/zimi/manifest.json b/homeassistant/components/zimi/manifest.json new file mode 100644 index 00000000000..d0dd3e09e06 --- /dev/null +++ b/homeassistant/components/zimi/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "zimi", + "name": "zimi", + "codeowners": ["@markhannon"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/zimi", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["zcc-helper==3.5"] +} diff --git a/homeassistant/components/zimi/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml new file mode 100644 index 00000000000..98e6c5b627c --- /dev/null +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -0,0 +1,99 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + There are no service actions. + appropriate-polling: + status: done + comment: | + There is no polling of the entities. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: + status: done + comment: | + https://mark_hannon@bitbucket.org/mark_hannon/zcc.git + docs-actions: + status: exempt + comment: | + There are no service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: done + action-exceptions: todo + reauthentication-flow: + status: exempt + comment: | + There is no user authentication needed. + parallel-updates: + status: todo + comment: | + Test of parallel updates will be done before setting. + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options flow + + # Gold + entity-translations: todo + entity-device-class: + status: todo + comment: | + Will set device classes for subsequent entities - not relevant for light. + devices: done + entity-category: todo + entity-disabled-by-default: todo + discovery: + status: todo + comment: > + Discovery is supported for the case where the Zimi Cloud Controller(s) are + connected to a local LAN network. Discover is not supported if the Zimi + Cloud Controller(s) are not connected to the local LAN network. + stale-devices: todo + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: + status: todo + comment: | + New devices will be automatically added - but only when the zcc connection is re-established. + discovery-update-info: + status: todo + comment: > + Discovery is not supported. + repair-issues: todo + docs-use-cases: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-data-update: done + docs-known-limitations: done + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use web sessions. + strict-typing: + status: todo diff --git a/homeassistant/components/zimi/strings.json b/homeassistant/components/zimi/strings.json new file mode 100644 index 00000000000..530eb86ef05 --- /dev/null +++ b/homeassistant/components/zimi/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "user": { + "title": "Zimi - Discover device(s)", + "description": "Discover and auto-configure Zimi Cloud Connect device." + }, + "selection": { + "title": "Zimi - Select device", + "description": "Select Zimi Cloud Connect device to configure.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "selected_host_and_port": "Selected ZCC" + }, + "data_description": { + "host": "Mandatory - ZCC IP address.", + "port": "Mandatory - ZCC port number (default=5003).", + "selected_host_and_port": "Selected ZCC IP address and port number" + } + }, + "manual": { + "title": "Zimi - Configure device", + "description": "Enter details of your Zimi Cloud Connect device.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::zimi::config::step::selection::data_description::host%]", + "port": "[%key:component::zimi::config::step::selection::data_description::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "connection_refused": "Connection refused" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8174dfc60b1..680d0a7bb2c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -736,6 +736,7 @@ FLOWS = { "zerproc", "zeversolar", "zha", + "zimi", "zodiac", "zwave_js", "zwave_me", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5e97e4c6626..1b9e9216827 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7630,6 +7630,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "zimi": { + "name": "zimi", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "zodiac": { "integration_type": "hub", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 9d116efa284..8f902317db7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3155,6 +3155,9 @@ zabbix-utils==2.0.2 # homeassistant.components.zamg zamg==0.3.6 +# homeassistant.components.zimi +zcc-helper==3.5 + # homeassistant.components.zeroconf zeroconf==0.147.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b14067bfd17..79c1bcc79f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2554,6 +2554,9 @@ yt-dlp[default]==2025.03.31 # homeassistant.components.zamg zamg==0.3.6 +# homeassistant.components.zimi +zcc-helper==3.5 + # homeassistant.components.zeroconf zeroconf==0.147.0 diff --git a/tests/components/zimi/__init__.py b/tests/components/zimi/__init__.py new file mode 100644 index 00000000000..0e95ffc9c33 --- /dev/null +++ b/tests/components/zimi/__init__.py @@ -0,0 +1 @@ +"""Tests for the zimi component.""" diff --git a/tests/components/zimi/test_config_flow.py b/tests/components/zimi/test_config_flow.py new file mode 100644 index 00000000000..9ec0c624b6f --- /dev/null +++ b/tests/components/zimi/test_config_flow.py @@ -0,0 +1,371 @@ +"""Tests for the zimi config flow.""" + +from unittest.mock import MagicMock, patch + +import pytest +from zcc import ( + ControlPointCannotConnectError, + ControlPointConnectionRefusedError, + ControlPointDescription, + ControlPointError, + ControlPointInvalidHostError, + ControlPointTimeoutError, +) + +from homeassistant import config_entries +from homeassistant.components.zimi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + +INPUT_MAC = "aa:bb:cc:dd:ee:ff" +INPUT_MAC_EXTRA = "aa:bb:cc:dd:ee:ee" +INPUT_HOST = "192.168.1.100" +INPUT_HOST_EXTRA = "192.168.1.101" +INPUT_PORT = 5003 +INPUT_PORT_EXTRA = 5004 + +INVALID_INPUT_MAC = "xyz" +MISMATCHED_INPUT_MAC = "aa:bb:cc:dd:ee:ee" +SELECTED_HOST_AND_PORT = "selected_host_and_port" + + +@pytest.fixture +def discovery_mock(): + """Mock the ControlPointDiscoveryService.""" + with patch( + "homeassistant.components.zimi.config_flow.ControlPointDiscoveryService", + autospec=True, + ) as mock: + mock.return_value = mock + yield mock + + +async def test_user_discovery_success( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test user form transitions to creation if zcc discovery succeeds.""" + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT) + ] + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_user_discovery_success_selection( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test user form transitions via selection to creation if zcc discovery succeeds has multiple hosts.""" + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT), + ControlPointDescription(host=INPUT_HOST_EXTRA, port=INPUT_PORT_EXTRA), + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "selection" + assert result["errors"] == {} + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription( + host=INPUT_HOST_EXTRA, port=INPUT_PORT_EXTRA, mac=INPUT_MAC_EXTRA + ) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + SELECTED_HOST_AND_PORT: f"{INPUT_HOST_EXTRA}:{INPUT_PORT_EXTRA!s}", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "host": INPUT_HOST_EXTRA, + "port": INPUT_PORT_EXTRA, + "mac": format_mac(INPUT_MAC_EXTRA), + } + + +async def test_user_discovery_duplicates( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test that flow is aborted if duplicates are added.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id=INPUT_MAC, + data={ + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + "mac": format_mac(INPUT_MAC), + }, + ).add_to_hass(hass) + + discovery_mock.discovers.return_value = [ + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT) + ] + + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_finish_manual_success( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions to creation with valid data.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_manual_cannot_connect( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions via cannot_connect to creation.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {} + + # First attempt fails with CANNOT_CONNECT when attempting to connect + discovery_mock.return_value.validate_connection.side_effect = ( + ControlPointCannotConnectError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {"base": "cannot_connect"} + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +async def test_manual_gethostbyname_error( + hass: HomeAssistant, + discovery_mock: MagicMock, +) -> None: + """Test manual form transitions via gethostbyname failure to creation.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {} + + # First attempt fails with name lookup failure when attempting to connect + discovery_mock.return_value.validate_connection.side_effect = ( + ControlPointInvalidHostError + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] + assert result["errors"] == {"base": "invalid_host"} + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + } + + +@pytest.mark.parametrize( + ("side_effect", "error_expected"), + [ + ( + ControlPointInvalidHostError, + {"base": "invalid_host"}, + ), + ( + ControlPointConnectionRefusedError, + {"base": "connection_refused"}, + ), + ( + ControlPointCannotConnectError, + {"base": "cannot_connect"}, + ), + ( + ControlPointTimeoutError, + {"base": "timeout"}, + ), + ( + Exception, + {"base": "unknown"}, + ), + ], +) +async def test_manual_connection_errors( + hass: HomeAssistant, + discovery_mock: MagicMock, + side_effect: Exception, + error_expected: dict, +) -> None: + """Test manual form connection errors.""" + + discovery_mock.discovers.side_effect = ControlPointError("Discovery failed") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {} + + # First attempt fails with connection errors + discovery_mock.return_value.validate_connection.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == error_expected + + # Second attempt succeeds + discovery_mock.return_value.validate_connection.side_effect = None + discovery_mock.return_value.validate_connection.return_value = ( + ControlPointDescription(host=INPUT_HOST, port=INPUT_PORT, mac=INPUT_MAC) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: INPUT_HOST, + CONF_PORT: INPUT_PORT, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"ZIMI Controller ({INPUT_HOST}:{INPUT_PORT})" + assert result["data"] == { + "host": INPUT_HOST, + "port": INPUT_PORT, + "mac": format_mac(INPUT_MAC), + }