diff --git a/CODEOWNERS b/CODEOWNERS index f43cdf457c8..4ef40a79bd1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -637,6 +637,8 @@ build.json @home-assistant/supervisor /tests/components/homeassistant_sky_connect/ @home-assistant/core /homeassistant/components/homeassistant_yellow/ @home-assistant/core /tests/components/homeassistant_yellow/ @home-assistant/core +/homeassistant/components/homee/ @Taraman17 +/tests/components/homee/ @Taraman17 /homeassistant/components/homekit/ @bdraco /tests/components/homekit/ @bdraco /homeassistant/components/homekit_controller/ @Jc2k @bdraco diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py new file mode 100644 index 00000000000..ed5dd69767f --- /dev/null +++ b/homeassistant/components/homee/__init__.py @@ -0,0 +1,85 @@ +"""The Homee integration.""" + +import logging + +from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.COVER] + +type HomeeConfigEntry = ConfigEntry[Homee] + + +async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool: + """Set up homee from a config entry.""" + # Create the Homee api object using host, user, + # password & pyHomee instance from the config + homee = Homee( + host=entry.data[CONF_HOST], + user=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + device="HA_" + hass.config.location_name, + reconnect_interval=10, + max_retries=100, + ) + + # Start the homee websocket connection as a new task + # and wait until we are connected + try: + await homee.get_access_token() + except HomeeConnectionFailedException as exc: + raise ConfigEntryNotReady( + f"Connection to Homee failed: {exc.__cause__}" + ) from exc + except HomeeAuthFailedException as exc: + raise ConfigEntryNotReady( + f"Authentication to Homee failed: {exc.__cause__}" + ) from exc + + hass.loop.create_task(homee.run()) + await homee.wait_until_connected() + + entry.runtime_data = homee + entry.async_on_unload(homee.disconnect) + + async def _connection_update_callback(connected: bool) -> None: + """Call when the device is notified of changes.""" + if connected: + _LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST]) + else: + _LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST]) + + await homee.add_connection_listener(_connection_update_callback) + + # create device register entry + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={ + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address)) + }, + identifiers={(DOMAIN, homee.settings.uid)}, + manufacturer="homee", + name=homee.settings.homee_name, + model="homee", + sw_version=homee.settings.version, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool: + """Unload a homee config entry.""" + # Unload platforms + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py new file mode 100644 index 00000000000..61d2a3f25a5 --- /dev/null +++ b/homeassistant/components/homee/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow for homee integration.""" + +import logging +from typing import Any + +from pyHomee import ( + Homee, + HomeeAuthFailedException as HomeeAuthenticationFailedException, + HomeeConnectionFailedException, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +AUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for homee.""" + + VERSION = 1 + + homee: Homee + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial user step.""" + + errors = {} + if user_input is not None: + self.homee = Homee( + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + try: + await self.homee.get_access_token() + except HomeeConnectionFailedException: + errors["base"] = "cannot_connect" + except HomeeAuthenticationFailedException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + _LOGGER.info("Got access token for homee") + self.hass.loop.create_task(self.homee.run()) + _LOGGER.debug("Homee task created") + await self.homee.wait_until_connected() + _LOGGER.info("Homee connected") + self.homee.disconnect() + _LOGGER.debug("Homee disconnecting") + await self.homee.wait_until_disconnected() + _LOGGER.info("Homee config successfully tested") + + await self.async_set_unique_id(self.homee.settings.uid) + + self._abort_if_unique_id_configured() + + _LOGGER.info( + "Created new homee entry with ID %s", self.homee.settings.uid + ) + + return self.async_create_entry( + title=f"{self.homee.settings.homee_name} ({self.homee.host})", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=AUTH_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py new file mode 100644 index 00000000000..c96165ead81 --- /dev/null +++ b/homeassistant/components/homee/const.py @@ -0,0 +1,4 @@ +"""Constants for the homee integration.""" + +# General +DOMAIN = "homee" diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py new file mode 100644 index 00000000000..c6546596fa7 --- /dev/null +++ b/homeassistant/components/homee/cover.py @@ -0,0 +1,261 @@ +"""The homee cover platform.""" + +import logging +from typing import Any, cast + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeNodeEntity + +_LOGGER = logging.getLogger(__name__) + +OPEN_CLOSE_ATTRIBUTES = [ + AttributeType.OPEN_CLOSE, + AttributeType.SLAT_ROTATION_IMPULSE, + AttributeType.UP_DOWN, +] +POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION] + + +def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute: + """Return the attribute used for opening/closing the cover.""" + # We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them. + if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None: + open_close = node.get_attribute_by_type(AttributeType.OPEN_CLOSE) + + return open_close + + +def get_cover_features( + node: HomeeNode, open_close_attribute: HomeeAttribute +) -> CoverEntityFeature: + """Determine the supported cover features of a homee node based on the available attributes.""" + features = CoverEntityFeature(0) + + if open_close_attribute.editable: + features |= ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + # Check for up/down position settable. + attribute = node.get_attribute_by_type(AttributeType.POSITION) + if attribute is not None: + if attribute.editable: + features |= CoverEntityFeature.SET_POSITION + + if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None: + features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT + + if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None: + features |= CoverEntityFeature.SET_TILT_POSITION + + return features + + +def get_device_class(node: HomeeNode) -> CoverDeviceClass | None: + """Determine the device class a homee node based on the node profile.""" + COVER_DEVICE_PROFILES = { + NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE, + NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER, + } + + return COVER_DEVICE_PROFILES.get(node.profile) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Add the homee platform for the cover integration.""" + + async_add_devices( + HomeeCover(node, config_entry) + for node in config_entry.runtime_data.nodes + if is_cover_node(node) + ) + + +def is_cover_node(node: HomeeNode) -> bool: + """Determine if a node is controllable as a homee cover based on its profile and attributes.""" + return node.profile in [ + NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH, + NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION, + NodeProfile.GARAGE_DOOR_OPERATOR, + NodeProfile.SHUTTER_POSITION_SWITCH, + ] + + +class HomeeCover(HomeeNodeEntity, CoverEntity): + """Representation of a homee cover device.""" + + _attr_name = None + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize a homee cover entity.""" + super().__init__(node, entry) + self._open_close_attribute = get_open_close_attribute(node) + self._attr_supported_features = get_cover_features( + node, self._open_close_attribute + ) + self._attr_device_class = get_device_class(node) + + self._attr_unique_id = f"{self._attr_unique_id}-{self._open_close_attribute.id}" + + @property + def current_cover_position(self) -> int | None: + """Return the cover's position.""" + # Translate the homee position values to HA's 0-100 scale + if self.has_attribute(AttributeType.POSITION): + attribute = self._node.get_attribute_by_type(AttributeType.POSITION) + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = attribute.current_value + position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100 + + return 100 - position + + return None + + @property + def current_cover_tilt_position(self) -> int | None: + """Return the cover's tilt position.""" + # Translate the homee position values to HA's 0-100 scale + if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION): + attribute = self._node.get_attribute_by_type( + AttributeType.SHUTTER_SLAT_POSITION + ) + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = attribute.current_value + position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100 + + return 100 - position + + return None + + @property + def is_opening(self) -> bool | None: + """Return the opening status of the cover.""" + if self._open_close_attribute is not None: + return ( + self._open_close_attribute.get_value() == 3 + if not self._open_close_attribute.is_reversed + else self._open_close_attribute.get_value() == 4 + ) + + return None + + @property + def is_closing(self) -> bool | None: + """Return the closing status of the cover.""" + if self._open_close_attribute is not None: + return ( + self._open_close_attribute.get_value() == 4 + if not self._open_close_attribute.is_reversed + else self._open_close_attribute.get_value() == 3 + ) + + return None + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + if self.has_attribute(AttributeType.POSITION): + attribute = self._node.get_attribute_by_type(AttributeType.POSITION) + return attribute.get_value() == attribute.maximum + + if self._open_close_attribute is not None: + if not self._open_close_attribute.is_reversed: + return self._open_close_attribute.get_value() == 1 + + return self._open_close_attribute.get_value() == 0 + + # If none of the above is present, it might be a slat only cover. + if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION): + attribute = self._node.get_attribute_by_type( + AttributeType.SHUTTER_SLAT_POSITION + ) + return attribute.get_value() == attribute.minimum + + return None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + if not self._open_close_attribute.is_reversed: + await self.async_set_value(self._open_close_attribute, 0) + else: + await self.async_set_value(self._open_close_attribute, 1) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + if not self._open_close_attribute.is_reversed: + await self.async_set_value(self._open_close_attribute, 1) + else: + await self.async_set_value(self._open_close_attribute, 0) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + if CoverEntityFeature.SET_POSITION in self.supported_features: + position = 100 - cast(int, kwargs[ATTR_POSITION]) + + # Convert position to range of our entity. + attribute = self._node.get_attribute_by_type(AttributeType.POSITION) + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = (position / 100) * (homee_max - homee_min) + homee_min + + await self.async_set_value(AttributeType.POSITION, homee_position) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.async_set_value(self._open_close_attribute, 2) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + slat_attribute = self._node.get_attribute_by_type( + AttributeType.SLAT_ROTATION_IMPULSE + ) + if not slat_attribute.is_reversed: + await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2) + else: + await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + slat_attribute = self._node.get_attribute_by_type( + AttributeType.SLAT_ROTATION_IMPULSE + ) + if not slat_attribute.is_reversed: + await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1) + else: + await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + if CoverEntityFeature.SET_TILT_POSITION in self.supported_features: + position = 100 - cast(int, kwargs[ATTR_TILT_POSITION]) + + # Convert position to range of our entity. + attribute = self._node.get_attribute_by_type( + AttributeType.SHUTTER_SLAT_POSITION + ) + homee_min = attribute.minimum + homee_max = attribute.maximum + homee_position = (position / 100) * (homee_max - homee_min) + homee_min + + await self.async_set_value( + AttributeType.SHUTTER_SLAT_POSITION, homee_position + ) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py new file mode 100644 index 00000000000..c3c2d860cc0 --- /dev/null +++ b/homeassistant/components/homee/entity.py @@ -0,0 +1,88 @@ +"""Base Entities for Homee integration.""" + +from pyHomee.const import AttributeType, NodeProfile, NodeState +from pyHomee.model import HomeeNode + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from . import HomeeConfigEntry +from .const import DOMAIN +from .helpers import get_name_for_enum + + +class HomeeNodeEntity(Entity): + """Representation of an Entity that uses more than one HomeeAttribute.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None: + """Initialize the wrapper using a HomeeNode and target entity.""" + self._node = node + self._attr_unique_id = f"{entry.runtime_data.settings.uid}-{node.id}" + self._entry = entry + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(node.id))}, + name=node.name, + model=get_name_for_enum(NodeProfile, node.profile), + sw_version=self._get_software_version(), + via_device=(DOMAIN, entry.runtime_data.settings.uid), + ) + self._host_connected = entry.runtime_data.connected + + async def async_added_to_hass(self) -> None: + """Add the homee binary sensor device to home assistant.""" + self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated)) + self.async_on_remove( + await self._entry.runtime_data.add_connection_listener( + self._on_connection_changed + ) + ) + + @property + def available(self) -> bool: + """Return the availability of the underlying node.""" + return self._node.state == NodeState.AVAILABLE and self._host_connected + + async def async_update(self) -> None: + """Fetch new state data for this node.""" + # Base class requests the whole node, if only a single attribute is needed + # the platform will overwrite this method. + homee = self._entry.runtime_data + await homee.update_node(self._node.id) + + def _get_software_version(self) -> str | None: + """Return the software version of the node.""" + if self.has_attribute(AttributeType.FIRMWARE_REVISION): + return self._node.get_attribute_by_type( + AttributeType.FIRMWARE_REVISION + ).get_value() + if self.has_attribute(AttributeType.SOFTWARE_REVISION): + return self._node.get_attribute_by_type( + AttributeType.SOFTWARE_REVISION + ).get_value() + return None + + def has_attribute(self, attribute_type: AttributeType) -> bool: + """Check if an attribute of the given type exists.""" + return attribute_type in self._node.attribute_map + + async def async_set_value(self, attribute_type: int, value: float) -> None: + """Set an attribute value on the homee node.""" + await self.async_set_value_by_id( + self._node.get_attribute_by_type(attribute_type).id, value + ) + + async def async_set_value_by_id(self, attribute_id: int, value: float) -> None: + """Set an attribute value on the homee node.""" + homee = self._entry.runtime_data + await homee.set_value(self._node.id, attribute_id, value) + + def _on_node_updated(self, node: HomeeNode) -> None: + self.schedule_update_ha_state() + + async def _on_connection_changed(self, connected: bool) -> None: + self._host_connected = connected + self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/helpers.py b/homeassistant/components/homee/helpers.py new file mode 100644 index 00000000000..30826d7f47c --- /dev/null +++ b/homeassistant/components/homee/helpers.py @@ -0,0 +1,16 @@ +"""Helper functions for the homee custom component.""" + +import logging + +_LOGGER = logging.getLogger(__name__) + + +def get_name_for_enum(att_class, att_id) -> str: + """Return the enum item name for a given integer.""" + try: + attribute_name = att_class(att_id).name + except ValueError: + _LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__) + return "Unknown" + + return attribute_name diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json new file mode 100644 index 00000000000..5869a9760ea --- /dev/null +++ b/homeassistant/components/homee/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "homee", + "name": "Homee", + "codeowners": ["@Taraman17"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/homee", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["homee"], + "quality_scale": "bronze", + "requirements": ["pyHomee==1.2.0"] +} diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml new file mode 100644 index 00000000000..96d4678b420 --- /dev/null +++ b/homeassistant/components/homee/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: exempt + comment: Integration is push based. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional 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 + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json new file mode 100644 index 00000000000..54f80ba2977 --- /dev/null +++ b/homeassistant/components/homee/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "flow_title": "Homee {name} ({host})", + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "title": "Configure homee", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The IP address of your Homee.", + "username": "The username for your Homee.", + "password": "The password for your Homee." + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f3e82d4d085..14061d2e960 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -255,6 +255,7 @@ FLOWS = { "holiday", "home_connect", "homeassistant_sky_connect", + "homee", "homekit", "homekit_controller", "homematicip_cloud", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index af5b510b222..96ca8a9f766 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2600,6 +2600,12 @@ "integration_type": "virtual", "supported_by": "netatmo" }, + "homee": { + "name": "Homee", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "homematic": { "name": "Homematic", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index 49e407b212e..42dd293043b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1759,6 +1759,9 @@ pyEmby==1.10 # homeassistant.components.hikvision pyHik==0.3.2 +# homeassistant.components.homee +pyHomee==1.2.0 + # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77530c77f05..2f129b8c4e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1448,6 +1448,9 @@ pyDuotecno==2024.10.1 # homeassistant.components.electrasmart pyElectra==1.2.4 +# homeassistant.components.homee +pyHomee==1.2.0 + # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py new file mode 100644 index 00000000000..03095aca7df --- /dev/null +++ b/tests/components/homee/__init__.py @@ -0,0 +1 @@ +"""Tests for the homee component.""" diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py new file mode 100644 index 00000000000..881a24656f3 --- /dev/null +++ b/tests/components/homee/conftest.py @@ -0,0 +1,68 @@ +"""Fixtures for Homee integration tests.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +HOMEE_ID = "00055511EECC" +HOMEE_IP = "192.168.1.11" +HOMEE_NAME = "TestHomee" +TESTUSER = "testuser" +TESTPASS = "testpass" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=f"{HOMEE_NAME} ({HOMEE_IP})", + domain=DOMAIN, + data={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + unique_id=HOMEE_ID, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.homee.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_homee() -> Generator[AsyncMock]: + """Return a mock Homee instance.""" + with ( + patch( + "homeassistant.components.homee.config_flow.Homee", autospec=True + ) as mocked_homee, + patch( + "homeassistant.components.homee.Homee", + autospec=True, + ), + ): + homee = mocked_homee.return_value + + homee.host = HOMEE_IP + homee.user = TESTUSER + homee.password = TESTPASS + homee.settings = MagicMock() + homee.settings.uid = HOMEE_ID + homee.settings.homee_name = HOMEE_NAME + homee.reconnect_interval = 10 + + homee.get_access_token.return_value = "test_token" + + yield homee diff --git a/tests/components/homee/test_config_flow.py b/tests/components/homee/test_config_flow.py new file mode 100644 index 00000000000..4dfe8226d16 --- /dev/null +++ b/tests/components/homee/test_config_flow.py @@ -0,0 +1,132 @@ +"""Test the Homee config flow.""" + +from unittest.mock import AsyncMock + +from pyHomee import HomeeAuthFailedException, HomeeConnectionFailedException +import pytest + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import HOMEE_ID, HOMEE_IP, HOMEE_NAME, TESTPASS, TESTUSER + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_homee", "mock_config_entry", "mock_setup_entry") +async def test_config_flow( + hass: HomeAssistant, +) -> None: + """Test the complete config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + "host": HOMEE_IP, + "username": TESTUSER, + "password": TESTPASS, + } + assert result["title"] == f"{HOMEE_NAME} ({HOMEE_IP})" + assert result["result"].unique_id == HOMEE_ID + + +@pytest.mark.parametrize( + ("side_eff", "error"), + [ + ( + HomeeConnectionFailedException("connection timed out"), + {"base": "cannot_connect"}, + ), + ( + HomeeAuthFailedException("wrong username or password"), + {"base": "invalid_auth"}, + ), + ( + Exception, + {"base": "unknown"}, + ), + ], +) +async def test_config_flow_errors( + hass: HomeAssistant, + mock_homee: AsyncMock, + side_eff: Exception, + error: dict[str, str], +) -> None: + """Test the config flow fails as expected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + flow_id = result["flow_id"] + + mock_homee.get_access_token.side_effect = side_eff + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == error + + mock_homee.get_access_token.side_effect = None + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_homee") +async def test_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow aborts when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"