diff --git a/.coveragerc b/.coveragerc index cc96377a65d..24f01b0c463 100644 --- a/.coveragerc +++ b/.coveragerc @@ -802,6 +802,11 @@ omit = homeassistant/components/orvibo/switch.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py + homeassistant/components/overkiz/__init__.py + homeassistant/components/overkiz/coordinator.py + homeassistant/components/overkiz/entity.py + homeassistant/components/overkiz/executor.py + homeassistant/components/overkiz/sensor.py homeassistant/components/ovo_energy/__init__.py homeassistant/components/ovo_energy/const.py homeassistant/components/ovo_energy/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index bfad230f22c..217ffb0b22b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -668,6 +668,8 @@ homeassistant/components/opnsense/* @mtreinish tests/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu +homeassistant/components/overkiz/* @imicknl @vlebourl @tetienne +tests/components/overkiz/* @imicknl @vlebourl @tetienne homeassistant/components/ovo_energy/* @timmo001 tests/components/ovo_energy/* @timmo001 homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py new file mode 100644 index 00000000000..77c02daf2a6 --- /dev/null +++ b/homeassistant/components/overkiz/__init__.py @@ -0,0 +1,125 @@ +"""The Overkiz (by Somfy) integration.""" +from dataclasses import dataclass +import logging + +from aiohttp import ClientError, ServerDisconnectedError +from pyoverkiz.client import OverkizClient +from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.exceptions import ( + BadCredentialsException, + MaintenanceException, + TooManyRequestsException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import ( + CONF_HUB, + DOMAIN, + PLATFORMS, + UPDATE_INTERVAL, + UPDATE_INTERVAL_ALL_ASSUMED_STATE, +) +from .coordinator import OverkizDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class HomeAssistantOverkizData: + """Overkiz data stored in the Home Assistant data object.""" + + coordinator: OverkizDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Overkiz from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + server = SUPPORTED_SERVERS[entry.data[CONF_HUB]] + + # To allow users with multiple accounts/hubs, we create a new session so they have separate cookies + session = async_create_clientsession(hass) + client = OverkizClient( + username=username, password=password, session=session, server=server + ) + + try: + await client.login() + setup = await client.get_setup() + except BadCredentialsException: + _LOGGER.error("Invalid authentication") + return False + except TooManyRequestsException as exception: + raise ConfigEntryNotReady("Too many requests, try again later") from exception + except (TimeoutError, ClientError, ServerDisconnectedError) as exception: + raise ConfigEntryNotReady("Failed to connect") from exception + except MaintenanceException as exception: + raise ConfigEntryNotReady("Server is down for maintenance") from exception + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + return False + + coordinator = OverkizDataUpdateCoordinator( + hass, + _LOGGER, + name="device events", + client=client, + devices=setup.devices, + places=setup.root_place, + update_interval=UPDATE_INTERVAL, + config_entry_id=entry.entry_id, + ) + + await coordinator.async_config_entry_first_refresh() + + if coordinator.is_stateless: + _LOGGER.debug( + "All devices have an assumed state. Update interval has been reduced to: %s", + UPDATE_INTERVAL_ALL_ASSUMED_STATE, + ) + coordinator.update_interval = UPDATE_INTERVAL_ALL_ASSUMED_STATE + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantOverkizData( + coordinator=coordinator, + ) + + # Map Overkiz device to Home Assistant platform + for device in coordinator.data.values(): + _LOGGER.debug( + "The following device has been retrieved. Report an issue if not supported correctly (%s)", + device, + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + device_registry = await dr.async_get_registry(hass) + + for gateway in setup.gateways: + _LOGGER.debug("Added gateway (%s)", gateway) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, gateway.id)}, + model=gateway.sub_type.beautify_name if gateway.sub_type else None, + manufacturer=server.manufacturer, + name=gateway.type.beautify_name, + sw_version=gateway.connectivity.protocol_version, + configuration_url=server.configuration_url, + ) + + 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): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py new file mode 100644 index 00000000000..673a946d35f --- /dev/null +++ b/homeassistant/components/overkiz/config_flow.py @@ -0,0 +1,110 @@ +"""Config flow for Overkiz (by Somfy) integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientError +from pyoverkiz.client import OverkizClient +from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.exceptions import ( + BadCredentialsException, + MaintenanceException, + TooManyRequestsException, +) +from pyoverkiz.models import obfuscate_id +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import device_registry as dr + +from .const import CONF_HUB, DEFAULT_HUB, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Overkiz (by Somfy).""" + + VERSION = 1 + + async def async_validate_input(self, user_input: dict[str, Any]) -> None: + """Validate user credentials.""" + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + server = SUPPORTED_SERVERS[user_input[CONF_HUB]] + + async with OverkizClient( + username=username, password=password, server=server + ) as client: + await client.login() + + # Set first gateway as unique id + if gateways := await client.get_gateways(): + gateway_id = gateways[0].id + await self.async_set_unique_id(gateway_id) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step via config flow.""" + errors = {} + + if user_input: + try: + await self.async_validate_input(user_input) + except TooManyRequestsException: + errors["base"] = "too_many_requests" + except BadCredentialsException: + errors["base"] = "invalid_auth" + except (TimeoutError, ClientError): + errors["base"] = "cannot_connect" + except MaintenanceException: + errors["base"] = "server_in_maintenance" + except Exception as exception: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(exception) + else: + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_HUB, default=DEFAULT_HUB): vol.In( + {key: hub.name for key, hub in SUPPORTED_SERVERS.items()} + ), + } + ), + errors=errors, + ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle DHCP discovery.""" + hostname = discovery_info.hostname + gateway_id = hostname[8:22] + + _LOGGER.debug("DHCP discovery detected gateway %s", obfuscate_id(gateway_id)) + + if self._gateway_already_configured(gateway_id): + _LOGGER.debug("Gateway %s is already configured", obfuscate_id(gateway_id)) + return self.async_abort(reason="already_configured") + + return await self.async_step_user() + + def _gateway_already_configured(self, gateway_id: str) -> bool: + """See if we already have a gateway matching the id.""" + device_registry = dr.async_get(self.hass) + return bool( + device_registry.async_get_device( + identifiers={(DOMAIN, gateway_id)}, connections=set() + ) + ) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py new file mode 100644 index 00000000000..ff5dea13c65 --- /dev/null +++ b/homeassistant/components/overkiz/const.py @@ -0,0 +1,27 @@ +"""Constants for the Overkiz (by Somfy) integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +from pyoverkiz.enums import UIClass +from pyoverkiz.enums.ui import UIWidget + +from homeassistant.const import Platform + +DOMAIN: Final = "overkiz" + +CONF_HUB: Final = "hub" +DEFAULT_HUB: Final = "somfy_europe" + +UPDATE_INTERVAL: Final = timedelta(seconds=30) +UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60) + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + +IGNORED_OVERKIZ_DEVICES: list[UIClass | UIWidget] = [ + UIClass.PROTOCOL_GATEWAY, + UIClass.POD, +] diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py new file mode 100644 index 00000000000..b258742fc6c --- /dev/null +++ b/homeassistant/components/overkiz/coordinator.py @@ -0,0 +1,189 @@ +"""Helpers to help coordinate updates.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +import json +import logging +from typing import Any + +from aiohttp import ServerDisconnectedError +from pyoverkiz.client import OverkizClient +from pyoverkiz.enums import EventName, ExecutionState +from pyoverkiz.exceptions import ( + BadCredentialsException, + MaintenanceException, + NotAuthenticatedException, + TooManyRequestsException, +) +from pyoverkiz.models import DataType, Device, Place, State + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +DATA_TYPE_TO_PYTHON: dict[DataType, Callable[[DataType], Any]] = { + DataType.INTEGER: int, + DataType.DATE: int, + DataType.STRING: str, + DataType.FLOAT: float, + DataType.BOOLEAN: bool, + DataType.JSON_ARRAY: json.loads, + DataType.JSON_OBJECT: json.loads, +} + +_LOGGER = logging.getLogger(__name__) + + +class OverkizDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from Overkiz platform.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + name: str, + client: OverkizClient, + devices: list[Device], + places: Place, + update_interval: timedelta | None = None, + config_entry_id: str, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + ) + + self.data = {} + self.client = client + self.devices: dict[str, Device] = {d.device_url: d for d in devices} + self.is_stateless = all( + device.device_url.startswith("rts://") + or device.device_url.startswith("internal://") + for device in devices + ) + self.executions: dict[str, dict[str, str]] = {} + self.areas = self.places_to_area(places) + self._config_entry_id = config_entry_id + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch Overkiz data via event listener.""" + try: + events = await self.client.fetch_events() + except BadCredentialsException as exception: + raise UpdateFailed("Invalid authentication") from exception + except TooManyRequestsException as exception: + raise UpdateFailed("Too many requests, try again later.") from exception + except MaintenanceException as exception: + raise UpdateFailed("Server is down for maintenance.") from exception + except TimeoutError as exception: + raise UpdateFailed("Failed to connect.") from exception + except (ServerDisconnectedError, NotAuthenticatedException): + self.executions = {} + + # During the relogin, similar exceptions can be thrown. + try: + await self.client.login() + self.devices = await self._get_devices() + except BadCredentialsException as exception: + raise UpdateFailed("Invalid authentication") from exception + except TooManyRequestsException as exception: + raise UpdateFailed("Too many requests, try again later.") from exception + + return self.devices + except Exception as exception: + _LOGGER.debug(exception) + raise UpdateFailed(exception) from exception + + for event in events: + _LOGGER.debug(event) + + if event.name == EventName.DEVICE_AVAILABLE: + self.devices[event.device_url].available = True + + elif event.name in [ + EventName.DEVICE_UNAVAILABLE, + EventName.DEVICE_DISABLED, + ]: + self.devices[event.device_url].available = False + + elif event.name in [ + EventName.DEVICE_CREATED, + EventName.DEVICE_UPDATED, + ]: + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._config_entry_id) + ) + + elif event.name == EventName.DEVICE_REMOVED: + base_device_url, *_ = event.device_url.split("#") + registry = await device_registry.async_get_registry(self.hass) + + if registered_device := registry.async_get_device( + {(DOMAIN, base_device_url)} + ): + registry.async_remove_device(registered_device.id) + + del self.devices[event.device_url] + + elif event.name == EventName.DEVICE_STATE_CHANGED: + for state in event.device_states: + device = self.devices[event.device_url] + if state.name not in device.states: + device.states[state.name] = state + device.states[state.name].value = self._get_state(state) + + elif event.name == EventName.EXECUTION_REGISTERED: + if event.exec_id not in self.executions: + self.executions[event.exec_id] = {} + + if not self.is_stateless: + self.update_interval = timedelta(seconds=1) + + elif ( + event.name == EventName.EXECUTION_STATE_CHANGED + and event.exec_id in self.executions + and event.new_state in [ExecutionState.COMPLETED, ExecutionState.FAILED] + ): + del self.executions[event.exec_id] + + if not self.executions: + self.update_interval = UPDATE_INTERVAL + + return self.devices + + async def _get_devices(self) -> dict[str, Device]: + """Fetch devices.""" + _LOGGER.debug("Fetching all devices and state via /setup/devices") + return {d.device_url: d for d in await self.client.get_devices(refresh=True)} + + @staticmethod + def _get_state( + state: State, + ) -> dict[Any, Any] | list[Any] | float | int | bool | str | None: + """Cast string value to the right type.""" + data_type = DataType(state.type) + + if data_type == DataType.NONE: + return state.value + + cast_to_python = DATA_TYPE_TO_PYTHON[data_type] + return cast_to_python(state.value) + + def places_to_area(self, place): + """Convert places with sub_places to a flat dictionary.""" + areas = {} + if isinstance(place, Place): + areas[place.oid] = place.label + + if isinstance(place.sub_places, list): + for sub_place in place.sub_places: + areas.update(self.places_to_area(sub_place)) + + return areas diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py new file mode 100644 index 00000000000..1a52a03ab36 --- /dev/null +++ b/homeassistant/components/overkiz/entity.py @@ -0,0 +1,108 @@ +"""Parent class for every Overkiz device.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyoverkiz.enums import OverkizAttribute, OverkizState +from pyoverkiz.models import Device + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OverkizDataUpdateCoordinator +from .executor import OverkizExecutor + + +class OverkizEntity(CoordinatorEntity): + """Representation of an Overkiz device entity.""" + + coordinator: OverkizDataUpdateCoordinator + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Initialize the device.""" + super().__init__(coordinator) + self.device_url = device_url + self.base_device_url, *_ = self.device_url.split("#") + self.executor = OverkizExecutor(device_url, coordinator) + + self._attr_assumed_state = not self.device.states + self._attr_available = self.device.available + self._attr_unique_id = self.device.device_url + self._attr_name = self.device.label + + self._attr_device_info = self.generate_device_info() + + @property + def device(self) -> Device: + """Return Overkiz device linked to this entity.""" + return self.coordinator.data[self.device_url] + + def generate_device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + # Some devices, such as the Smart Thermostat have several devices in one physical device, + # with same device url, terminated by '#' and a number. + # In this case, we use the base device url as the device identifier. + if "#" in self.device_url and not self.device_url.endswith("#1"): + # Only return the url of the base device, to inherit device name and model from parent device. + return { + "identifiers": {(DOMAIN, self.executor.base_device_url)}, + } + + manufacturer = ( + self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER) + or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME) + or self.coordinator.client.server.manufacturer + ) + + model = ( + self.executor.select_state( + OverkizState.CORE_MODEL, + OverkizState.CORE_PRODUCT_MODEL_NAME, + OverkizState.IO_MODEL, + ) + or self.device.widget + ) + + return DeviceInfo( + identifiers={(DOMAIN, self.executor.base_device_url)}, + name=self.device.label, + manufacturer=manufacturer, + model=model, + sw_version=self.executor.select_attribute( + OverkizAttribute.CORE_FIRMWARE_REVISION + ), + hw_version=self.device.controllable_name, + suggested_area=self.coordinator.areas[self.device.place_oid], + via_device=self.executor.get_gateway_id(), + configuration_url=self.coordinator.client.server.configuration_url, + ) + + +@dataclass +class OverkizSensorDescription(SensorEntityDescription): + """Class to describe an Overkiz sensor.""" + + native_value: Callable[ + [str | int | float], str | int | float + ] | None = lambda val: val + + +class OverkizDescriptiveEntity(OverkizEntity): + """Representation of a Overkiz device entity based on a description.""" + + def __init__( + self, + device_url: str, + coordinator: OverkizDataUpdateCoordinator, + description: OverkizSensorDescription, + ) -> None: + """Initialize the device.""" + super().__init__(device_url, coordinator) + self.entity_description = description + self._attr_name = f"{super().name} {self.entity_description.name}" + self._attr_unique_id = f"{super().unique_id}-{self.entity_description.key}" diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py new file mode 100644 index 00000000000..52e21255728 --- /dev/null +++ b/homeassistant/components/overkiz/executor.py @@ -0,0 +1,132 @@ +"""Class for helpers and communication with the OverKiz API.""" +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlparse + +from pyoverkiz.models import Command, Device + +from .coordinator import OverkizDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class OverkizExecutor: + """Representation of an Overkiz device with execution handler.""" + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Initialize the executor.""" + self.device_url = device_url + self.coordinator = coordinator + self.base_device_url = self.device_url.split("#")[0] + + @property + def device(self) -> Device: + """Return Overkiz device linked to this entity.""" + return self.coordinator.data[self.device_url] + + def select_command(self, *commands: str) -> str | None: + """Select first existing command in a list of commands.""" + existing_commands = self.device.definition.commands + return next((c for c in commands if c in existing_commands), None) + + def has_command(self, *commands: str) -> bool: + """Return True if a command exists in a list of commands.""" + return self.select_command(*commands) is not None + + def select_state(self, *states) -> str | None: + """Select first existing active state in a list of states.""" + for state in states: + if current_state := self.device.states[state]: + return current_state.value + + return None + + def has_state(self, *states: str) -> bool: + """Return True if a state exists in self.""" + return self.select_state(*states) is not None + + def select_attribute(self, *attributes) -> str | None: + """Select first existing active state in a list of states.""" + for attribute in attributes: + if current_attribute := self.device.attributes[attribute]: + return current_attribute.value + + return None + + async def async_execute_command(self, command_name: str, *args: Any): + """Execute device command in async context.""" + try: + exec_id = await self.coordinator.client.execute_command( + self.device.device_url, + Command(command_name, list(args)), + "Home Assistant", + ) + except Exception as exception: # pylint: disable=broad-except + _LOGGER.error(exception) + return + + # ExecutionRegisteredEvent doesn't contain the device_url, thus we need to register it here + self.coordinator.executions[exec_id] = { + "device_url": self.device.device_url, + "command_name": command_name, + } + + await self.coordinator.async_refresh() + + async def async_cancel_command(self, commands_to_cancel: list[str]) -> bool: + """Cancel running execution by command.""" + + # Cancel a running execution + # Retrieve executions initiated via Home Assistant from Data Update Coordinator queue + exec_id = next( + ( + exec_id + # Reverse dictionary to cancel the last added execution + for exec_id, execution in reversed(self.coordinator.executions.items()) + if execution.get("device_url") == self.device.device_url + and execution.get("command_name") in commands_to_cancel + ), + None, + ) + + if exec_id: + await self.async_cancel_execution(exec_id) + return True + + # Retrieve executions initiated outside Home Assistant via API + executions = await self.coordinator.client.get_current_executions() + exec_id = next( + ( + execution.id + for execution in executions + # Reverse dictionary to cancel the last added execution + for action in reversed(execution.action_group.get("actions")) + for command in action.get("commands") + if action.get("device_url") == self.device.device_url + and command.get("name") in commands_to_cancel + ), + None, + ) + + if exec_id: + await self.async_cancel_execution(exec_id) + return True + + return False + + async def async_cancel_execution(self, exec_id: str): + """Cancel running execution via execution id.""" + await self.coordinator.client.cancel_command(exec_id) + + def get_gateway_id(self): + """ + Retrieve gateway id from device url. + + device URL (:///[#]) + """ + url = urlparse(self.device_url) + return url.netloc diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json new file mode 100644 index 00000000000..1278028d5e8 --- /dev/null +++ b/homeassistant/components/overkiz/manifest.json @@ -0,0 +1,21 @@ +{ + "domain": "overkiz", + "name": "Overkiz (by Somfy)", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/overkiz", + "requirements": [ + "pyoverkiz==1.0.0" + ], + "dhcp": [ + { + "hostname": "gateway*", + "macaddress": "F8811A*" + } + ], + "codeowners": [ + "@imicknl", + "@vlebourl", + "@tetienne" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py new file mode 100644 index 00000000000..2f933055507 --- /dev/null +++ b/homeassistant/components/overkiz/sensor.py @@ -0,0 +1,418 @@ +"""Support for Overkiz sensors.""" +from __future__ import annotations + +from pyoverkiz.enums import OverkizAttribute, OverkizState, UIWidget + +from homeassistant.components.overkiz import HomeAssistantOverkizData +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + ENERGY_WATT_HOUR, + LIGHT_LUX, + PERCENTAGE, + POWER_WATT, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, + TIME_SECONDS, + VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + VOLUME_LITERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from .coordinator import OverkizDataUpdateCoordinator +from .entity import OverkizDescriptiveEntity, OverkizEntity, OverkizSensorDescription + +SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ + OverkizSensorDescription( + key=OverkizState.CORE_BATTERY_LEVEL, + name="Battery Level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OverkizSensorDescription( + key=OverkizState.CORE_BATTERY, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + native_value=lambda value: str(value).capitalize(), + entity_category=EntityCategory.DIAGNOSTIC, + ), + OverkizSensorDescription( + key=OverkizState.CORE_RSSI_LEVEL, + name="RSSI Level", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_value=lambda value: round(float(value)), + entity_category=EntityCategory.DIAGNOSTIC, + ), + OverkizSensorDescription( + key=OverkizState.CORE_EXPECTED_NUMBER_OF_SHOWER, + name="Expected Number Of Shower", + icon="mdi:shower-head", + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_NUMBER_OF_SHOWER_REMAINING, + name="Number of Shower Remaining", + icon="mdi:shower-head", + state_class=SensorStateClass.MEASUREMENT, + ), + # V40 is measured in litres (L) and shows the amount of warm (mixed) water with a temperature of 40 C, which can be drained from a switched off electric water heater. + OverkizSensorDescription( + key=OverkizState.CORE_V40_WATER_VOLUME_ESTIMATION, + name="Water Volume Estimation at 40 °C", + icon="mdi:water", + native_unit_of_measurement=VOLUME_LITERS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_WATER_CONSUMPTION, + name="Water Consumption", + icon="mdi:water", + native_unit_of_measurement=VOLUME_LITERS, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.IO_OUTLET_ENGINE, + name="Outlet Engine", + icon="mdi:fan-chevron-down", + native_unit_of_measurement=VOLUME_LITERS, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.IO_INLET_ENGINE, + name="Inlet Engine", + icon="mdi:fan-chevron-up", + native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.HLRRWIFI_ROOM_TEMPERATURE, + name="Room Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + OverkizSensorDescription( + key=OverkizState.IO_MIDDLE_WATER_TEMPERATURE, + name="Middle Water Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + ), + OverkizSensorDescription( + key=OverkizState.CORE_FOSSIL_ENERGY_CONSUMPTION, + name="Fossil Energy Consumption", + device_class=SensorDeviceClass.ENERGY, + ), + OverkizSensorDescription( + key=OverkizState.CORE_GAS_CONSUMPTION, + name="Gas Consumption", + ), + OverkizSensorDescription( + key=OverkizState.CORE_THERMAL_ENERGY_CONSUMPTION, + name="Thermal Energy Consumption", + ), + # LightSensor/LuminanceSensor + OverkizSensorDescription( + key=OverkizState.CORE_LUMINANCE, + name="Luminance", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, # core:MeasuredValueType = core:LuminanceInLux + state_class=SensorStateClass.MEASUREMENT, + ), + # ElectricitySensor/CumulativeElectricPowerConsumptionSensor + OverkizSensorDescription( + key=OverkizState.CORE_ELECTRIC_ENERGY_CONSUMPTION, + name="Electric Energy Consumption", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh (not for modbus:YutakiV2DHWElectricalEnergyConsumptionComponent) + state_class=SensorStateClass.TOTAL_INCREASING, # core:MeasurementCategory attribute = electric/overall + ), + OverkizSensorDescription( + key=OverkizState.CORE_ELECTRIC_POWER_CONSUMPTION, + name="Electric Power Consumption", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, # core:MeasuredValueType = core:ElectricalEnergyInWh (not for modbus:YutakiV2DHWElectricalEnergyConsumptionComponent) + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF1, + name="Consumption Tariff 1", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF2, + name="Consumption Tariff 2", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF3, + name="Consumption Tariff 3", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF4, + name="Consumption Tariff 4", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF5, + name="Consumption Tariff 5", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF6, + name="Consumption Tariff 6", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF7, + name="Consumption Tariff 7", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF8, + name="Consumption Tariff 8", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONSUMPTION_TARIFF9, + name="Consumption Tariff 9", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_WATT_HOUR, # core:MeasuredValueType = core:ElectricalEnergyInWh + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + # HumiditySensor/RelativeHumiditySensor + OverkizSensorDescription( + key=OverkizState.CORE_RELATIVE_HUMIDITY, + name="Relative Humidity", + native_value=lambda value: round(float(value), 2), + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, # core:MeasuredValueType = core:RelativeValueInPercentage + state_class=SensorStateClass.MEASUREMENT, + ), + # TemperatureSensor/TemperatureSensor + OverkizSensorDescription( + key=OverkizState.CORE_TEMPERATURE, + name="Temperature", + native_value=lambda value: round(float(value), 2), + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, # core:MeasuredValueType = core:TemperatureInCelcius + state_class=SensorStateClass.MEASUREMENT, + ), + # WeatherSensor/WeatherForecastSensor + OverkizSensorDescription( + key=OverkizState.CORE_WEATHER_STATUS, + name="Weather Status", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_MINIMUM_TEMPERATURE, + name="Minimum Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + OverkizSensorDescription( + key=OverkizState.CORE_MAXIMUM_TEMPERATURE, + name="Maximum Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + # AirSensor/COSensor + OverkizSensorDescription( + key=OverkizState.CORE_CO_CONCENTRATION, + name="CO Concentration", + device_class=SensorDeviceClass.CO, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + # AirSensor/CO2Sensor + OverkizSensorDescription( + key=OverkizState.CORE_CO2_CONCENTRATION, + name="CO2 Concentration", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + # SunSensor/SunEnergySensor + OverkizSensorDescription( + key=OverkizState.CORE_SUN_ENERGY, + name="Sun Energy", + native_value=lambda value: round(float(value), 2), + device_class=SensorDeviceClass.ENERGY, + icon="mdi:solar-power", + state_class=SensorStateClass.MEASUREMENT, + ), + # WindSensor/WindSpeedSensor + OverkizSensorDescription( + key=OverkizState.CORE_WIND_SPEED, + name="Wind Speed", + native_value=lambda value: round(float(value), 2), + icon="mdi:weather-windy", + state_class=SensorStateClass.MEASUREMENT, + ), + # SmokeSensor/SmokeSensor + OverkizSensorDescription( + key=OverkizState.IO_SENSOR_ROOM, + name="Sensor Room", + native_value=lambda value: str(value).capitalize(), + entity_registry_enabled_default=False, + ), + OverkizSensorDescription( + key=OverkizState.IO_PRIORITY_LOCK_ORIGINATOR, + name="Priority Lock Originator", + native_value=lambda value: str(value).capitalize(), + icon="mdi:lock", + entity_registry_enabled_default=False, + ), + OverkizSensorDescription( + key=OverkizState.CORE_PRIORITY_LOCK_TIMER, + name="Priority Lock Timer", + icon="mdi:lock-clock", + native_unit_of_measurement=TIME_SECONDS, + entity_registry_enabled_default=False, + ), + OverkizSensorDescription( + key=OverkizState.CORE_DISCRETE_RSSI_LEVEL, + name="Discrete RSSI Level", + entity_registry_enabled_default=False, + native_value=lambda value: str(value).capitalize(), + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # DomesticHotWaterProduction/WaterHeatingSystem + OverkizSensorDescription( + key=OverkizState.IO_HEAT_PUMP_OPERATING_TIME, + name="Heat Pump Operating Time", + ), + OverkizSensorDescription( + key=OverkizState.IO_ELECTRIC_BOOSTER_OPERATING_TIME, + name="Electric Booster Operating Time", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +): + """Set up the Overkiz sensors from a config entry.""" + data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + entities: list[SensorEntity] = [] + + key_supported_states = { + description.key: description for description in SENSOR_DESCRIPTIONS + } + + for device in data.coordinator.data.values(): + if ( + device.widget not in IGNORED_OVERKIZ_DEVICES + and device.ui_class not in IGNORED_OVERKIZ_DEVICES + ): + for state in device.definition.states: + if description := key_supported_states.get(state.qualified_name): + entities.append( + OverkizStateSensor( + device.device_url, + data.coordinator, + description, + ) + ) + + if device.widget == UIWidget.HOMEKIT_STACK: + entities.append( + OverkizHomeKitSetupCodeSensor( + device.device_url, + data.coordinator, + ) + ) + + async_add_entities(entities) + + +class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): + """Representation of an Overkiz Sensor.""" + + @property + def native_value(self): + """Return the value of the sensor.""" + state = self.device.states.get(self.entity_description.key) + + if not state: + return None + + # Transform the value with a lambda function + if hasattr(self.entity_description, "native_value"): + return self.entity_description.native_value(state.value) + + return state.value + + +class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity): + """Representation of an Overkiz HomeKit Setup Code.""" + + _attr_icon = "mdi:shield-home" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Initialize the device.""" + super().__init__(device_url, coordinator) + self._attr_name = "HomeKit Setup Code" + + @property + def native_value(self): + """Return the value of the sensor.""" + return self.device.attributes.get(OverkizAttribute.HOMEKIT_SETUP_CODE).value + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + # By default this sensor will be listed at a virtual HomekitStack device, + # but it makes more sense to show this at the gateway device in the entity registry. + return { + "identifiers": {(DOMAIN, self.executor.get_gateway_id())}, + } diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json new file mode 100644 index 00000000000..57fea0d3ffb --- /dev/null +++ b/homeassistant/components/overkiz/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "description": "The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo), Rexel (Energeasy Connect) and Atlantic (Cozytouch). Enter your application credentials and select your hub.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "hub": "Hub" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "server_in_maintenance": "Server is down for maintenance", + "too_many_requests": "Too many requests, try again later.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/en.json b/homeassistant/components/overkiz/translations/en.json new file mode 100644 index 00000000000..f15fe84c3ed --- /dev/null +++ b/homeassistant/components/overkiz/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 473caaa44c8..928ac3dad67 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -224,6 +224,7 @@ FLOWS = [ "opentherm_gw", "openuv", "openweathermap", + "overkiz", "ovo_energy", "owntracks", "ozw", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index e68d7883181..e5bb2551427 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -201,6 +201,11 @@ DHCP = [ "domain": "nuki", "hostname": "nuki_bridge_*" }, + { + "domain": "overkiz", + "hostname": "gateway*", + "macaddress": "F8811A*" + }, { "domain": "powerwall", "hostname": "1118431-*", diff --git a/requirements_all.txt b/requirements_all.txt index f2a1aa593a1..5551a3d5675 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1724,6 +1724,9 @@ pyotgw==1.1b1 # homeassistant.components.otp pyotp==2.6.0 +# homeassistant.components.overkiz +pyoverkiz==1.0.0 + # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a222c5e968c..e9ca671e7b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1066,6 +1066,9 @@ pyotgw==1.1b1 # homeassistant.components.otp pyotp==2.6.0 +# homeassistant.components.overkiz +pyoverkiz==1.0.0 + # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py new file mode 100644 index 00000000000..c9c745c9665 --- /dev/null +++ b/tests/components/overkiz/test_config_flow.py @@ -0,0 +1,191 @@ +"""Tests for Overkiz (by Somfy) config flow.""" +from __future__ import annotations + +from unittest.mock import Mock, patch + +from aiohttp import ClientError +from pyoverkiz.exceptions import ( + BadCredentialsException, + MaintenanceException, + TooManyRequestsException, +) +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, mock_device_registry + +TEST_EMAIL = "test@testdomain.com" +TEST_EMAIL2 = "test@testdomain.nl" +TEST_PASSWORD = "test-password" +TEST_PASSWORD2 = "test-password2" +TEST_HUB = "somfy_europe" +TEST_HUB2 = "hi_kumo_europe" +TEST_GATEWAY_ID = "1234-5678-9123" +TEST_GATEWAY_ID2 = "4321-5678-9123" + +MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["errors"] == {} + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + ), patch( + "homeassistant.components.overkiz.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_EMAIL + assert result2["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_HUB, + } + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "side_effect, error", + [ + (BadCredentialsException, "invalid_auth"), + (TooManyRequestsException, "too_many_requests"), + (TimeoutError, "cannot_connect"), + (ClientError, "cannot_connect"), + (MaintenanceException, "server_in_maintenance"), + (Exception, "unknown"), + ], +) +async def test_form_invalid_auth( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": error} + + +async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: + """Test config flow aborts Config Flow on duplicate entries.""" + MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + +async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: + """Test config flow allows Config Flow unique entries.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="test2@testdomain.com", + data={"username": "test2@testdomain.com", "password": TEST_PASSWORD}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_EMAIL + assert result2["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_HUB, + } + + +async def test_dhcp_flow(hass: HomeAssistant) -> None: + """Test that DHCP discovery for new bridge works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp.DhcpServiceInfo( + hostname="gateway-1234-5678-9123", + ip="192.168.1.4", + macaddress="F8811A000000", + ), + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + +async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: + """Test that DHCP doesn't setup already configured gateways.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_EMAIL, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + config_entry.add_to_hass(hass) + + device_registry = mock_device_registry(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "1234-5678-9123")}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp.DhcpServiceInfo( + hostname="gateway-1234-5678-9123", + ip="192.168.1.4", + macaddress="F8811A000000", + ), + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured"