diff --git a/.strict-typing b/.strict-typing index 14f1540c533..91043cd4563 100644 --- a/.strict-typing +++ b/.strict-typing @@ -278,6 +278,7 @@ homeassistant.components.imap.* homeassistant.components.imgw_pib.* homeassistant.components.immich.* homeassistant.components.incomfort.* +homeassistant.components.inels.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* diff --git a/CODEOWNERS b/CODEOWNERS index 27a93ee9094..9cfa84d0e93 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -741,6 +741,8 @@ build.json @home-assistant/supervisor /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh /tests/components/incomfort/ @jbouwh +/homeassistant/components/inels/ @epdevlab +/tests/components/inels/ @epdevlab /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 /homeassistant/components/inkbird/ @bdraco diff --git a/homeassistant/components/inels/__init__.py b/homeassistant/components/inels/__init__.py new file mode 100644 index 00000000000..cdfa4e3ed20 --- /dev/null +++ b/homeassistant/components/inels/__init__.py @@ -0,0 +1,95 @@ +"""The iNELS integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from inelsmqtt import InelsMqtt +from inelsmqtt.devices import Device +from inelsmqtt.discovery import InelsDiscovery + +from homeassistant.components import mqtt as ha_mqtt +from homeassistant.components.mqtt import ( + ReceiveMessage, + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import LOGGER, PLATFORMS + +type InelsConfigEntry = ConfigEntry[InelsData] + + +@dataclass +class InelsData: + """Represents the data structure for INELS runtime data.""" + + mqtt: InelsMqtt + devices: list[Device] + + +async def async_setup_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool: + """Set up iNELS from a config entry.""" + + async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None: + """Publish an MQTT message using the Home Assistant MQTT client.""" + await ha_mqtt.async_publish(hass, topic, payload, qos, retain) + + async def mqtt_subscribe( + sub_state: dict[str, Any] | None, + topic: str, + callback_func: Callable[[str, str], None], + ) -> dict[str, Any]: + """Subscribe to MQTT topics using the Home Assistant MQTT client.""" + + @callback + def mqtt_message_received(msg: ReceiveMessage) -> None: + """Handle iNELS mqtt messages.""" + # Payload is always str at runtime since we don't set encoding=None + # HA uses UTF-8 by default + callback_func(msg.topic, msg.payload) # type: ignore[arg-type] + + topics = { + "inels_subscribe_topic": { + "topic": topic, + "msg_callback": mqtt_message_received, + } + } + + sub_state = async_prepare_subscribe_topics(hass, sub_state, topics) + await async_subscribe_topics(hass, sub_state) + return sub_state + + async def mqtt_unsubscribe(sub_state: dict[str, Any]) -> None: + async_unsubscribe_topics(hass, sub_state) + + if not await ha_mqtt.async_wait_for_mqtt_client(hass): + LOGGER.error("MQTT integration not available") + raise ConfigEntryNotReady("MQTT integration not available") + + inels_mqtt = InelsMqtt(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe) + devices: list[Device] = await InelsDiscovery(inels_mqtt).start() + + # If no devices are discovered, continue with the setup + if not devices: + LOGGER.info("No devices discovered") + + entry.runtime_data = InelsData(mqtt=inels_mqtt, devices=devices) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.mqtt.unsubscribe_topics() + entry.runtime_data.mqtt.unsubscribe_listeners() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/inels/config_flow.py b/homeassistant/components/inels/config_flow.py new file mode 100644 index 00000000000..73c953ff239 --- /dev/null +++ b/homeassistant/components/inels/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for iNELS.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import DOMAIN, TITLE + + +class INelsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle of iNELS config flow.""" + + VERSION = 1 + + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by MQTT discovery.""" + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + + # Validate the message, abort if it fails. + if not discovery_info.topic.endswith("/gw"): + # Not an iNELS discovery message. + return self.async_abort(reason="invalid_discovery_info") + if not discovery_info.payload: + # Empty payload, unexpected payload. + return self.async_abort(reason="invalid_discovery_info") + + return await self.async_step_confirm_from_mqtt() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + try: + if not mqtt.is_connected(self.hass): + return self.async_abort(reason="mqtt_not_connected") + except KeyError: + return self.async_abort(reason="mqtt_not_configured") + + return await self.async_step_confirm_from_user() + + async def step_confirm( + self, step_id: str, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + + if user_input is not None: + await self.async_set_unique_id(DOMAIN) + return self.async_create_entry(title=TITLE, data={}) + + return self.async_show_form(step_id=step_id) + + async def async_step_confirm_from_mqtt( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup from MQTT discovered.""" + return await self.step_confirm( + step_id="confirm_from_mqtt", user_input=user_input + ) + + async def async_step_confirm_from_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup from user add integration.""" + return await self.step_confirm( + step_id="confirm_from_user", user_input=user_input + ) diff --git a/homeassistant/components/inels/const.py b/homeassistant/components/inels/const.py new file mode 100644 index 00000000000..2f407887d4d --- /dev/null +++ b/homeassistant/components/inels/const.py @@ -0,0 +1,14 @@ +"""Constants for the iNELS integration.""" + +import logging + +from homeassistant.const import Platform + +DOMAIN = "inels" +TITLE = "iNELS" + +PLATFORMS: list[Platform] = [ + Platform.SWITCH, +] + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/inels/entity.py b/homeassistant/components/inels/entity.py new file mode 100644 index 00000000000..592782ca5b7 --- /dev/null +++ b/homeassistant/components/inels/entity.py @@ -0,0 +1,61 @@ +"""Base class for iNELS components.""" + +from __future__ import annotations + +from inelsmqtt.devices import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class InelsBaseEntity(Entity): + """Base iNELS entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + device: Device, + key: str, + index: int, + ) -> None: + """Init base entity.""" + self._device = device + self._device_id = device.unique_id + self._attr_unique_id = self._device_id + + # The referenced variable to read from + self._key = key + # The index of the variable list to read from. '-1' for no index + self._index = index + + info = device.info() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + manufacturer=info.manufacturer, + model=info.model_number, + name=device.title, + sw_version=info.sw_version, + ) + + async def async_added_to_hass(self) -> None: + """Add subscription of the data listener.""" + # Register the HA callback + self._device.add_ha_callback(self._key, self._index, self._callback) + # Subscribe to MQTT updates + self._device.mqtt.subscribe_listener( + self._device.state_topic, self._device.unique_id, self._device.callback + ) + + def _callback(self) -> None: + """Get data from broker into the HA.""" + if hasattr(self, "hass"): + self.schedule_update_ha_state() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._device.is_available diff --git a/homeassistant/components/inels/icons.json b/homeassistant/components/inels/icons.json new file mode 100644 index 00000000000..aa111c31f52 --- /dev/null +++ b/homeassistant/components/inels/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "switch": { + "bit": { + "default": "mdi:power-socket-eu" + }, + "simple_relay": { + "default": "mdi:power-socket-eu" + }, + "relay": { + "default": "mdi:power-socket-eu" + } + } + } +} diff --git a/homeassistant/components/inels/manifest.json b/homeassistant/components/inels/manifest.json new file mode 100644 index 00000000000..2764983d5b2 --- /dev/null +++ b/homeassistant/components/inels/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "inels", + "name": "iNELS", + "codeowners": ["@epdevlab"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/inels", + "iot_class": "local_push", + "mqtt": ["inels/status/#"], + "quality_scale": "bronze", + "requirements": ["elkoep-aio-mqtt==0.1.0b4"], + "single_config_entry": true +} diff --git a/homeassistant/components/inels/quality_scale.yaml b/homeassistant/components/inels/quality_scale.yaml new file mode 100644 index 00000000000..732ff471707 --- /dev/null +++ b/homeassistant/components/inels/quality_scale.yaml @@ -0,0 +1,118 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: + status: done + comment: > + Raise "Invalid authentication" and "MQTT Broker is offline or + cannot be reached" otherwise, async_setup_entry returns False + appropriate-polling: + status: done + comment: | + Integration uses local_push. + entity-unique-id: + status: done + comment: | + {MAC}_{DEVICE_ID} is used, for example, 0e97f8b7d30_02E8. + has-entity-name: + status: done + comment: > + Almost all devices are multi-functional, which means that all functions + are equally important -> keep the descriptive name (not setting _attr_name to None). + entity-event-setup: + status: done + comment: | + Subscribe in async_added_to_hass & unsubscribe from async_unload_entry. + dependency-transparency: done + action-setup: + status: exempt + comment: | + No custom actions are defined. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: + status: done + comment: | + A link to the wiki is provided. + docs-removal-instructions: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: todo + entity-unavailable: + status: done + comment: | + available property. + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + reauthentication-flow: todo + parallel-updates: + status: todo + comment: | + For all platforms, add a constant PARALLEL_UPDATES = 0. + test-coverage: done + integration-owner: done + docs-installation-parameters: + status: done + comment: | + A link to the wiki is provided. + docs-configuration-parameters: + status: exempt + comment: > + There is the same options flow in the integration as there is in the + configuration. + + # Gold + entity-translations: done + entity-device-class: todo + devices: done + entity-category: todo + entity-disabled-by-default: todo + discovery: + status: todo + comment: | + Currently blocked by a hw limitation. + stale-devices: + status: todo + comment: > + Same as discovery. The async_remove_config_entry_device function should be + implemented at a minimum. + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: + status: todo + comment: | + Same as discovery. + repair-issues: todo + docs-use-cases: todo + docs-supported-devices: + status: todo + comment: > + In regards to this and below doc requirements, I am not sure whether the + wiki link is acceptable. + docs-supported-functions: todo + docs-data-update: todo + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + The integration is not making any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/inels/strings.json b/homeassistant/components/inels/strings.json new file mode 100644 index 00000000000..e7a81bf1868 --- /dev/null +++ b/homeassistant/components/inels/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "confirm_from_user": { + "description": "iNELS devices must be connected to the same broker as the Home Assistant MQTT integration client. Continue setup?" + }, + "confirm_from_mqtt": { + "description": "Do you want to set up iNELS?" + } + }, + "abort": { + "mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.", + "mqtt_not_configured": "Home Assistant MQTT integration not configured.", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + } + }, + "entity": { + "switch": { + "bit": { + "name": "Bit{addr}" + }, + "simple_relay": { + "name": "Simple relay{index}" + }, + "relay": { + "name": "Relay{index}" + } + } + } +} diff --git a/homeassistant/components/inels/switch.py b/homeassistant/components/inels/switch.py new file mode 100644 index 00000000000..22932e2c629 --- /dev/null +++ b/homeassistant/components/inels/switch.py @@ -0,0 +1,137 @@ +"""iNELS switch entity.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from inelsmqtt.devices import Device +from inelsmqtt.utils.common import Bit, Relay, SimpleRelay + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import InelsConfigEntry +from .entity import InelsBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class InelsSwitchEntityDescription(SwitchEntityDescription): + """Class describing iNELS switch entities.""" + + get_state_fn: Callable[[Device, int], Bit | SimpleRelay | Relay] + alerts: list[str] | None = None + placeholder_fn: Callable[[Device, int, bool], dict[str, str]] + + +SWITCH_TYPES = [ + InelsSwitchEntityDescription( + key="bit", + translation_key="bit", + get_state_fn=lambda device, index: device.state.bit[index], + placeholder_fn=lambda device, index, indexed: { + "addr": f" {device.state.bit[index].addr}" + }, + ), + InelsSwitchEntityDescription( + key="simple_relay", + translation_key="simple_relay", + get_state_fn=lambda device, index: device.state.simple_relay[index], + placeholder_fn=lambda device, index, indexed: { + "index": f" {index + 1}" if indexed else "" + }, + ), + InelsSwitchEntityDescription( + key="relay", + translation_key="relay", + get_state_fn=lambda device, index: device.state.relay[index], + alerts=["overflow"], + placeholder_fn=lambda device, index, indexed: { + "index": f" {index + 1}" if indexed else "" + }, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: InelsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Load iNELS switch.""" + entities: list[InelsSwitch] = [] + + for device in entry.runtime_data.devices: + for description in SWITCH_TYPES: + if hasattr(device.state, description.key): + switch_count = len(getattr(device.state, description.key)) + entities.extend( + InelsSwitch( + device=device, + description=description, + index=idx, + switch_count=switch_count, + ) + for idx in range(switch_count) + ) + + async_add_entities(entities, False) + + +class InelsSwitch(InelsBaseEntity, SwitchEntity): + """The platform class required by Home Assistant.""" + + entity_description: InelsSwitchEntityDescription + + def __init__( + self, + device: Device, + description: InelsSwitchEntityDescription, + index: int = 0, + switch_count: int = 1, + ) -> None: + """Initialize the switch.""" + super().__init__(device=device, key=description.key, index=index) + self.entity_description = description + self._switch_count = switch_count + + # Include index in unique_id for devices with multiple switches + unique_key = f"{description.key}{index}" if index else description.key + + self._attr_unique_id = f"{self._attr_unique_id}_{unique_key}".lower() + + # Set translation placeholders + self._attr_translation_placeholders = self.entity_description.placeholder_fn( + self._device, self._index, self._switch_count > 1 + ) + + def _check_alerts(self, current_state: Bit | SimpleRelay | Relay) -> None: + """Check if there are active alerts and raise ServiceValidationError if found.""" + if self.entity_description.alerts and any( + getattr(current_state, alert_key, None) + for alert_key in self.entity_description.alerts + ): + raise ServiceValidationError("Cannot operate switch with active alerts") + + @property + def is_on(self) -> bool | None: + """Return if switch is on.""" + current_state = self.entity_description.get_state_fn(self._device, self._index) + return current_state.is_on + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the switch to turn off.""" + current_state = self.entity_description.get_state_fn(self._device, self._index) + self._check_alerts(current_state) + current_state.is_on = False + await self._device.set_ha_value(self._device.state) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the switch to turn on.""" + current_state = self.entity_description.get_state_fn(self._device, self._index) + self._check_alerts(current_state) + current_state.is_on = True + await self._device.set_ha_value(self._device.state) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7ef474bce63..30799f93a93 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -304,6 +304,7 @@ FLOWS = { "immich", "improv_ble", "incomfort", + "inels", "inkbird", "insteon", "intellifire", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index adcbc4275a8..ee3c036109a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3029,6 +3029,13 @@ "integration_type": "virtual", "supported_by": "opower" }, + "inels": { + "name": "iNELS", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "single_config_entry": true + }, "influxdb": { "name": "InfluxDB", "integration_type": "hub", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index c4eb8708b0e..aca0a9293a2 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -16,6 +16,9 @@ MQTT = { "fully_kiosk": [ "fully/deviceInfo/+", ], + "inels": [ + "inels/status/#", + ], "pglab": [ "pglab/discovery/#", ], diff --git a/mypy.ini b/mypy.ini index 4ef7b2a826f..3bdb21e27b5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2536,6 +2536,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.inels.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1578514ff61..6aafb505b70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -876,6 +876,9 @@ eliqonline==1.2.2 # homeassistant.components.elkm1 elkm1-lib==2.2.11 +# homeassistant.components.inels +elkoep-aio-mqtt==0.1.0b4 + # homeassistant.components.elmax elmax-api==0.0.6.4rc0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ec24097da8..51b09c46457 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -764,6 +764,9 @@ elgato==5.1.2 # homeassistant.components.elkm1 elkm1-lib==2.2.11 +# homeassistant.components.inels +elkoep-aio-mqtt==0.1.0b4 + # homeassistant.components.elmax elmax-api==0.0.6.4rc0 diff --git a/tests/components/inels/__init__.py b/tests/components/inels/__init__.py new file mode 100644 index 00000000000..e5f262d1d34 --- /dev/null +++ b/tests/components/inels/__init__.py @@ -0,0 +1,3 @@ +"""Tests for the iNELS integration.""" + +HA_INELS_PATH = "homeassistant.components.inels" diff --git a/tests/components/inels/common.py b/tests/components/inels/common.py new file mode 100644 index 00000000000..33714e4a610 --- /dev/null +++ b/tests/components/inels/common.py @@ -0,0 +1,133 @@ +"""Common methods used across tests.""" + +from homeassistant.components import inels +from homeassistant.components.inels.const import DOMAIN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + +__all__ = [ + "MockConfigEntry", + "get_entity_id", + "get_entity_state", + "inels", + "old_entity_and_device_removal", + "set_mock_mqtt", +] + +MAC_ADDRESS = "001122334455" +UNIQUE_ID = "C0FFEE" +CONNECTED_INELS_VALUE = b"on\n" +DISCONNECTED_INELS_VALUE = b"off\n" + + +def get_entity_id(entity_config: dict, index: int) -> str: + """Construct the entity_id based on the entity_config.""" + unique_id = entity_config["unique_id"].lower() + base_id = f"{entity_config['entity_type']}.{MAC_ADDRESS}_{unique_id}_{entity_config['device_type']}" + return f"{base_id}{f'_{index:03}'}" if index is not None else base_id + + +def get_entity_state( + hass: HomeAssistant, entity_config: dict, index: int +) -> State | None: + """Return the state of the entity from the state machine.""" + entity_id = get_entity_id(entity_config, index) + return hass.states.get(entity_id) + + +def set_mock_mqtt( + mqtt, + config: dict, + status_value: bytes, + device_available: bool = True, + gw_available: bool = True, + last_value=None, +): + """Set mock mqtt communication.""" + gw_connected_value = '{"status":true}' if gw_available else '{"status":false}' + device_connected_value = ( + CONNECTED_INELS_VALUE if device_available else DISCONNECTED_INELS_VALUE + ) + + mqtt.mock_messages = { + config["gw_connected_topic"]: gw_connected_value, + config["connected_topic"]: device_connected_value, + config["status_topic"]: status_value, + } + mqtt.mock_discovery_all = {config["base_topic"]: status_value} + + if last_value is not None: + mqtt.mock_last_value = {config["status_topic"]: last_value} + else: + mqtt.mock_last_value = {} + + +async def old_entity_and_device_removal( + hass: HomeAssistant, mock_mqtt, platform, entity_config, value_key, index +): + """Test that old entities are correctly identified and removed across different platforms.""" + + set_mock_mqtt( + mock_mqtt, + config=entity_config, + status_value=entity_config[value_key], + gw_available=True, + device_available=True, + ) + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + title="iNELS", + ) + config_entry.add_to_hass(hass) + + # Create an old entity + entity_registry = er.async_get(hass) + old_entity = entity_registry.async_get_or_create( + domain=platform, + platform=DOMAIN, + unique_id=f"old_{entity_config['unique_id']}", + suggested_object_id=f"old_inels_{platform}_{entity_config['device_type']}", + config_entry=config_entry, + ) + + # Create a device and associate it with the old entity + device_registry = dr.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"old_{entity_config['unique_id']}")}, + name=f"iNELS {platform.capitalize()} {entity_config['device_type']}", + manufacturer="iNELS", + model=entity_config["device_type"], + ) + + # Associate the old entity with the device + entity_registry.async_update_entity(old_entity.entity_id, device_id=device.id) + + assert ( + device_registry.async_get_device({(DOMAIN, old_entity.unique_id)}) is not None + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # The device was discovered, and at this point, the async_remove_old_entities function was called + assert config_entry.runtime_data.devices + assert old_entity.entity_id not in config_entry.runtime_data.old_entities[platform] + + # Get the new entity + new_entity = entity_registry.async_get(get_entity_id(entity_config, index).lower()) + + assert new_entity is not None + + # Verify that the new entity is in the registry + assert entity_registry.async_get(new_entity.entity_id) is not None + + # Verify that the old entity is no longer in the registry + assert entity_registry.async_get(old_entity.entity_id) is None + + # Verify that the device no longer exists in the registry + assert device_registry.async_get_device({(DOMAIN, old_entity.unique_id)}) is None diff --git a/tests/components/inels/conftest.py b/tests/components/inels/conftest.py new file mode 100644 index 00000000000..5c242321230 --- /dev/null +++ b/tests/components/inels/conftest.py @@ -0,0 +1,119 @@ +"""Test fixtures.""" + +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from . import HA_INELS_PATH +from .common import DOMAIN, MockConfigEntry, get_entity_state, set_mock_mqtt + + +@pytest.fixture(name="mock_mqtt") +def mock_inelsmqtt_fixture(): + """Mock inels mqtt lib.""" + + def messages(): + """Return mocked messages.""" + return mqtt.mock_messages + + def last_value(topic): + """Mock last_value to return None if mock_last_value is empty or topic doesn't exist.""" + return mqtt.mock_last_value.get(topic) if mqtt.mock_last_value else None + + async def discovery_all(): + """Return mocked discovered devices.""" + return mqtt.mock_discovery_all + + async def subscribe(topic, qos=0, options=None, properties=None): + """Mock subscribe fnc.""" + if isinstance(topic, list): + return {t: mqtt.mock_messages.get(t) for t in topic} + return mqtt.mock_messages.get(topic) + + async def publish(topic, payload, qos=0, retain=True, properties=None): + """Mock publish to change value of the device.""" + mqtt.mock_messages[topic] = payload + + unsubscribe_topics = AsyncMock() + unsubscribe_listeners = Mock() + + mqtt = Mock( + messages=messages, + subscribe=subscribe, + publish=publish, + last_value=last_value, + discovery_all=discovery_all, + unsubscribe_topics=unsubscribe_topics, + unsubscribe_listeners=unsubscribe_listeners, + mock_last_value=dict[str, Any](), + mock_messages=dict[str, Any](), + mock_discovery_all=dict[str, Any](), + ) + + with ( + patch(f"{HA_INELS_PATH}.InelsMqtt", return_value=mqtt), + ): + yield mqtt + + +@pytest.fixture +def mock_reload_entry(): + """Mock the async_reload_entry function.""" + with patch(f"{HA_INELS_PATH}.async_reload_entry") as mock_reload: + yield mock_reload + + +@pytest.fixture +def setup_entity(hass: HomeAssistant, mock_mqtt): + """Set up an entity for testing with specified configuration and status.""" + + async def _setup( + entity_config, + status_value: bytes, + device_available: bool = True, + gw_available: bool = True, + last_value=None, + index: int | None = None, + ): + set_mock_mqtt( + mock_mqtt, + config=entity_config, + status_value=status_value, + gw_available=gw_available, + device_available=device_available, + last_value=last_value, + ) + await setup_inels_test_integration(hass) + return get_entity_state(hass, entity_config, index) + + return _setup + + +@pytest.fixture +def entity_config(request: pytest.FixtureRequest): + """Fixture to provide parameterized entity configuration.""" + # This fixture will be parameterized in each test file + return request.param + + +async def setup_inels_test_integration(hass: HomeAssistant): + """Load inels integration with mocked mqtt broker.""" + hass.config.components.add(DOMAIN) + + entry = MockConfigEntry( + data={}, + domain=DOMAIN, + title="iNELS", + ) + entry.add_to_hass(hass) + + with ( + patch(f"{HA_INELS_PATH}.ha_mqtt.async_wait_for_mqtt_client", return_value=True), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert DOMAIN in hass.config.components diff --git a/tests/components/inels/test_config_flow.py b/tests/components/inels/test_config_flow.py new file mode 100644 index 00000000000..921d12b7d57 --- /dev/null +++ b/tests/components/inels/test_config_flow.py @@ -0,0 +1,170 @@ +"""Test the iNELS config flow.""" + +from homeassistant.components.inels.const import DOMAIN, TITLE +from homeassistant.components.mqtt import MQTT_CONNECTION_STATE +from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from tests.common import MockConfigEntry +from tests.typing import MqttMockHAClient + + +async def test_mqtt_config_single_instance( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """The MQTT test flow is aborted if an entry already exists.""" + + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """When an MQTT message is received on the discovery topic, it triggers a config flow.""" + discovery_info = MqttServiceInfo( + topic="inels/status/MAC_ADDRESS/gw", + payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}', + qos=0, + retain=False, + subscribed_topic="inels/status/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["result"].data == {} + + +async def test_mqtt_abort_invalid_topic( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Check MQTT flow aborts if discovery topic is invalid.""" + discovery_info = MqttServiceInfo( + topic="inels/status/MAC_ADDRESS/wrong_topic", + payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}', + qos=0, + retain=False, + subscribed_topic="inels/status/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_abort_empty_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Check MQTT flow aborts if discovery payload is empty.""" + discovery_info = MqttServiceInfo( + topic="inels/status/MAC_ADDRESS/gw", + payload="", + qos=0, + retain=False, + subscribed_topic="inels/status/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_abort_already_in_progress( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that a second MQTT flow is aborted when one is already in progress.""" + discovery_info = MqttServiceInfo( + topic="inels/status/MAC_ADDRESS/gw", + payload='{"CUType":"CU3-08M","Status":"Runfast","FW":"02.97.18"}', + qos=0, + retain=False, + subscribed_topic="inels/status/#", + timestamp=None, + ) + + # Start first MQTT flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.FORM + + # Try to start second MQTT flow while first is in progress + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_user_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test if the user can finish a config flow.""" + 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"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["result"].data == {} + + +async def test_user_config_single_instance( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """The user test flow is aborted if an entry already exists.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_user_setup_mqtt_not_connected( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """The user setup test flow is aborted when MQTT is not connected.""" + + mqtt_mock.connected = False + async_dispatcher_send(hass, MQTT_CONNECTION_STATE, False) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_not_connected" + + +async def test_user_setup_mqtt_not_configured(hass: HomeAssistant) -> None: + """The user setup test flow is aborted when MQTT is not configured.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_not_configured" diff --git a/tests/components/inels/test_init.py b/tests/components/inels/test_init.py new file mode 100644 index 00000000000..e8b4e47687f --- /dev/null +++ b/tests/components/inels/test_init.py @@ -0,0 +1,102 @@ +"""Tests for iNELS integration.""" + +from unittest.mock import ANY, AsyncMock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import HA_INELS_PATH +from .common import DOMAIN, inels + +from tests.common import MockConfigEntry +from tests.typing import MqttMockHAClient + + +async def test_ha_mqtt_publish( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that MQTT publish function works correctly.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + with ( + patch(f"{HA_INELS_PATH}.InelsDiscovery") as mock_discovery_class, + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=None, + ), + ): + mock_discovery = AsyncMock() + mock_discovery.start.return_value = [] + mock_discovery_class.return_value = mock_discovery + + await inels.async_setup_entry(hass, config_entry) + + topic, payload, qos, retain = "test/topic", "test_payload", 1, True + + await config_entry.runtime_data.mqtt.publish(topic, payload, qos, retain) + mqtt_mock.async_publish.assert_called_once_with(topic, payload, qos, retain) + + +async def test_ha_mqtt_subscribe( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that MQTT subscribe function works correctly.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + with ( + patch(f"{HA_INELS_PATH}.InelsDiscovery") as mock_discovery_class, + patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", + return_value=None, + ), + ): + mock_discovery = AsyncMock() + mock_discovery.start.return_value = [] + mock_discovery_class.return_value = mock_discovery + + await inels.async_setup_entry(hass, config_entry) + + topic = "test/topic" + + await config_entry.runtime_data.mqtt.subscribe(topic) + mqtt_mock.async_subscribe.assert_any_call(topic, ANY, 0, "utf-8", None) + + +async def test_ha_mqtt_not_available( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that ConfigEntryNotReady is raised when MQTT is not available.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.mqtt.async_wait_for_mqtt_client", + return_value=False, + ), + pytest.raises(ConfigEntryNotReady, match="MQTT integration not available"), + ): + await inels.async_setup_entry(hass, config_entry) + + +async def test_unload_entry(hass: HomeAssistant, mock_mqtt) -> None: + """Test unload entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + config_entry.runtime_data = inels.InelsData(mqtt=mock_mqtt, devices=[]) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=True, + ) as mock_unload_platforms: + result = await inels.async_unload_entry(hass, config_entry) + + assert result is True + mock_mqtt.unsubscribe_topics.assert_called_once() + mock_mqtt.unsubscribe_listeners.assert_called_once() + mock_unload_platforms.assert_called_once() diff --git a/tests/components/inels/test_switch.py b/tests/components/inels/test_switch.py new file mode 100644 index 00000000000..14680012746 --- /dev/null +++ b/tests/components/inels/test_switch.py @@ -0,0 +1,167 @@ +"""iNELS switch platform testing.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .common import MAC_ADDRESS, UNIQUE_ID, get_entity_id + +DT_07 = "07" +DT_100 = "100" +DT_BITS = "bits" + + +@pytest.fixture(params=["simple_relay", "relay", "bit"]) +def entity_config(request: pytest.FixtureRequest): + """Fixture to provide parameterized entity configuration for switch tests.""" + configs = { + "simple_relay": { + "entity_type": "switch", + "device_type": "simple_relay", + "dev_type": DT_07, + "unique_id": UNIQUE_ID, + "gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw", + "connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}", + "status_topic": f"inels/status/{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}", + "base_topic": f"{MAC_ADDRESS}/{DT_07}/{UNIQUE_ID}", + "switch_on_value": "07\n01\n92\n09\n", + "switch_off_value": "07\n00\n92\n09\n", + }, + "relay": { + "entity_type": "switch", + "device_type": "relay", + "dev_type": DT_100, + "unique_id": UNIQUE_ID, + "gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw", + "connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}", + "status_topic": f"inels/status/{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}", + "base_topic": f"{MAC_ADDRESS}/{DT_100}/{UNIQUE_ID}", + "switch_on_value": "07\n00\n0A\n28\n00\n", + "switch_off_value": "06\n00\n0A\n28\n00\n", + "alerts": { + "overflow": "06\n00\n0A\n28\n01\n", + }, + }, + "bit": { + "entity_type": "switch", + "device_type": "bit", + "dev_type": DT_BITS, + "unique_id": UNIQUE_ID, + "gw_connected_topic": f"inels/connected/{MAC_ADDRESS}/gw", + "connected_topic": f"inels/connected/{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}", + "status_topic": f"inels/status/{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}", + "base_topic": f"{MAC_ADDRESS}/{DT_BITS}/{UNIQUE_ID}", + "switch_on_value": b'{"state":{"000":1,"001":1}}', + "switch_off_value": b'{"state":{"000":0,"001":0}}', + }, + } + return configs[request.param] + + +@pytest.mark.parametrize( + "entity_config", ["simple_relay", "relay", "bit"], indirect=True +) +@pytest.mark.parametrize( + ("gw_available", "device_available", "expected_state"), + [ + (True, False, STATE_UNAVAILABLE), + (False, True, STATE_UNAVAILABLE), + (True, True, STATE_ON), + ], +) +async def test_switch_availability( + hass: HomeAssistant, + setup_entity, + entity_config, + gw_available, + device_available, + expected_state, +) -> None: + """Test switch availability and state under different gateway and device availability conditions.""" + + switch_state = await setup_entity( + entity_config, + status_value=entity_config["switch_on_value"], + gw_available=gw_available, + device_available=device_available, + index=0 if entity_config["device_type"] == "bit" else None, + ) + + assert switch_state is not None + assert switch_state.state == expected_state + + +@pytest.mark.parametrize( + ("entity_config", "index"), + [ + ("simple_relay", None), + ("relay", None), + ("bit", 0), + ], + indirect=["entity_config"], +) +async def test_switch_turn_on( + hass: HomeAssistant, setup_entity, entity_config, index +) -> None: + """Test turning on a switch.""" + switch_state = await setup_entity( + entity_config, status_value=entity_config["switch_off_value"], index=index + ) + + assert switch_state is not None + assert switch_state.state == STATE_OFF + + with patch("inelsmqtt.devices.Device.set_ha_value") as mock_set_state: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: get_entity_id(entity_config, index)}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + ha_value = mock_set_state.call_args.args[0] + assert getattr(ha_value, entity_config["device_type"])[0].is_on is True + + +@pytest.mark.parametrize( + ("entity_config", "index"), + [ + ("simple_relay", None), + ("relay", None), + ("bit", 0), + ], + indirect=["entity_config"], +) +async def test_switch_turn_off( + hass: HomeAssistant, setup_entity, entity_config, index +) -> None: + """Test turning off a switch.""" + switch_state = await setup_entity( + entity_config, status_value=entity_config["switch_on_value"], index=index + ) + + assert switch_state is not None + assert switch_state.state == STATE_ON + + with patch("inelsmqtt.devices.Device.set_ha_value") as mock_set_state: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: get_entity_id(entity_config, index)}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + ha_value = mock_set_state.call_args.args[0] + assert getattr(ha_value, entity_config["device_type"])[0].is_on is False