diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..cc38153 --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,8 @@ +default_config: + +logger: + default: info + logs: + custom_components.elro_connects: debug +# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +# debugpy: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b67ccac --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ludeeus/container:integration-debian", + "name": "Elro Connects development", + "context": "..", + "appPort": [ + "9123:8123" + ], + "postCreateCommand": "container install", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5664314 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +pythonenv* +venv +.venv +.coverage +.idea \ No newline at end of file diff --git a/README.md b/README.md index 9234475..b2584c3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ # ha-elro-connects -Elro Connects for Home Assistant via HACS + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) + +[![hacs][hacsbadge]][hacs] +![Project Maintenance][maintenance-shield] + +[![Community Forum][forum-shield]][forum] + +**This component will set up the following platforms.** + +Platform | Description +-- | -- +`siren` | Represents Elro Connects alarms as a siren. Turn the siren `ON` to test it. Turn it `OFF` to silence the (test) alarm. + +Other platforms (sensor e.g. sensor for battery level and signal strength are to be added later) + +## Installation + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +2. If you do not have a `custom_components` directory (folder) there, you need to create it. +3. In the `custom_components` directory (folder) create a new folder called `elro_connects`. +4. Download _all_ the files from the `custom_components/elro_connects/` directory (folder) in this repository. +5. Place the files you downloaded in the new directory (folder) you created. +6. Restart Home Assistant +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Elro Connects" + +Using your HA configuration directory (folder) as a starting point you should now also have something like this: + +```text +custom_components/elro_connects/translations/en.json +custom_components/elro_connects/translations/nl.json +custom_components/elro_connects/__init__.py +custom_components/elro_connects/api.py +custom_components/elro_connects/siren.py +custom_components/elro_connects/config_flow.py +custom_components/elro_connects/const.py +custom_components/elro_connects/manifest.json +... +``` + +## Configuration is done in the UI + + + +## Contributions are welcome + +If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) + +*** + +[elro_connects]: https://github.com/jbouwh/ha-elro-connects +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge +[commits]: https://github.com/jbouwh/ha-elro-connects/commits/main +[hacs]: https://github.com/custom-components/hacs +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge +[forum]: https://community.home-assistant.io/ +[license-shield]: https://img.shields.io/github/license/custom-components/blueprint.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/elro_connects/blueprint.svg?style=for-the-badge +[releases]: https://github.com/jbouwh/ha-elro-connects/releases diff --git a/custom_components/elro_connects/__init__.py b/custom_components/elro_connects/__init__.py new file mode 100644 index 0000000..8570497 --- /dev/null +++ b/custom_components/elro_connects/__init__.py @@ -0,0 +1,91 @@ +"""The Elro Connects integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from elro.api import K1 + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, SERVICE_RELOAD, Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_CONNECTOR_ID, DEFAULT_INTERVAL, DOMAIN +from .device import ElroConnectsK1 + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SIREN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Elro Connects from a config entry.""" + + current_device_set: set | None = None + + async def _async_update_data() -> dict[int, dict]: + """Update data via API.""" + nonlocal current_device_set + try: + await elro_connects_api.async_update() + except K1.K1ConnectionError as err: + raise UpdateFailed(err) from err + new_set = set(elro_connects_api.data.keys()) + if current_device_set is None: + current_device_set = new_set + if new_set - current_device_set: + current_device_set = new_set + # New devices discovered, trigger a reload + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=False, + ) + return elro_connects_api.data + + async def async_reload(call: ServiceCall) -> None: + """Reload the integration.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN.title(), + update_method=_async_update_data, + update_interval=timedelta(seconds=DEFAULT_INTERVAL), + ) + elro_connects_api = ElroConnectsK1( + coordinator, + entry.data[CONF_HOST], + entry.data[CONF_CONNECTOR_ID], + entry.data[CONF_PORT], + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN] = {} + hass.data[DOMAIN][entry.entry_id] = elro_connects_api + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload( + entry.add_update_listener(elro_connects_api.async_update_settings) + ) + hass.helpers.service.async_register_admin_service( + DOMAIN, SERVICE_RELOAD, async_reload + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + elro_connects_api: ElroConnectsK1 = hass.data[DOMAIN][entry.entry_id] + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await elro_connects_api.async_disconnect() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/elro_connects/config_flow.py b/custom_components/elro_connects/config_flow.py new file mode 100644 index 0000000..da6f902 --- /dev/null +++ b/custom_components/elro_connects/config_flow.py @@ -0,0 +1,145 @@ +"""Config flow for Elro Connects integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from elro.api import K1 +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv + +from .const import CONF_CONNECTOR_ID, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ELRO_CONNECTS_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONNECTOR_ID): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +class K1ConnectionTest: + """Elro Connects K1 connection test.""" + + def __init__(self, host: str) -> None: + """Initialize.""" + self.host = host + + async def async_try_connection(self, connector_id: str, port: int) -> bool: + """Test if we can authenticate with the host.""" + connector = K1(self.host, connector_id, port) + try: + await connector.async_connect() + except K1.K1ConnectionError: + return False + finally: + await connector.async_disconnect() + return True + + +async def async_validate_input( + hass: HomeAssistant, data: dict[str, Any] +) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + hub = K1ConnectionTest(data["host"]) + + if not await hub.async_try_connection(data["connector_id"], data["port"]): + raise CannotConnect + + return {"title": "Elro Connects K1 Connector"} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Elro Connects.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Handle configuring options.""" + return OptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=ELRO_CONNECTS_DATA_SCHEMA + ) + + errors = {} + + try: + info = await async_validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_CONNECTOR_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=ELRO_CONNECTS_DATA_SCHEMA, errors=errors + ) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Manage the options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage configuration options.""" + errors = {} + entry_data = self.config_entry.data + if user_input is not None: + changed_input = {} + changed_input.update(user_input) + changed_input[CONF_CONNECTOR_ID] = entry_data.get(CONF_CONNECTOR_ID) + try: + await async_validate_input(self.hass, changed_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.hass.config_entries.async_update_entry( + self.config_entry, data=changed_input + ) + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=entry_data.get(CONF_HOST)): str, + vol.Required(CONF_PORT, default=entry_data.get(CONF_PORT)): cv.port, + } + ), + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/custom_components/elro_connects/const.py b/custom_components/elro_connects/const.py new file mode 100644 index 0000000..21afd26 --- /dev/null +++ b/custom_components/elro_connects/const.py @@ -0,0 +1,8 @@ +"""Constants for the Elro Connects integration.""" + +DOMAIN = "elro_connects" + +DEFAULT_INTERVAL = 15 +DEFAULT_PORT = 1025 + +CONF_CONNECTOR_ID = "connector_id" diff --git a/custom_components/elro_connects/device.py b/custom_components/elro_connects/device.py new file mode 100644 index 0000000..fcf743d --- /dev/null +++ b/custom_components/elro_connects/device.py @@ -0,0 +1,118 @@ +"""Elro Connects K1 device communication.""" +from __future__ import annotations + +import logging + +from elro.api import K1 +from elro.command import GET_ALL_EQUIPMENT_STATUS, GET_DEVICE_NAMES +from elro.utils import update_state_data + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME, CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ElroConnectsK1(K1): + """Communicate with the Elro Connects K1 adapter.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + ipaddress: str, + k1_id: str, + port: int = 1025, + ) -> None: + """Initialize the K1 connector.""" + self._coordinator = coordinator + self._data: dict[int, dict] = {} + K1.__init__(self, ipaddress, k1_id, port) + + async def async_update(self) -> None: + """Synchronize with the K1 connector.""" + await self.async_connect() + update_status = await self.async_process_command(GET_ALL_EQUIPMENT_STATUS) + self._data = update_status + update_names = await self.async_process_command(GET_DEVICE_NAMES) + update_state_data(self._data, update_names) + + async def async_update_settings( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Process updated settings.""" + await self.async_configure(entry.data[CONF_HOST], entry.data[CONF_PORT]) + + @property + def data(self) -> dict[int, dict]: + """Return the synced state.""" + return self._data + + @property + def coordinator(self) -> DataUpdateCoordinator: + """Return the data update coordinator.""" + return self._coordinator + + +class ElroConnectsEntity(CoordinatorEntity): + """Defines a base entity for Elro Connects devices.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + connector_id: str, + device_id: int, + description: EntityDescription, + ) -> None: + """Initialize the Elro connects entity.""" + super().__init__(coordinator) + + self.data: dict = coordinator.data[device_id] + + self._connector_id = connector_id + self._device_id = device_id + self._attr_device_class = description.device_class + self._attr_icon = description.icon + self._attr_unique_id = f"{connector_id}-{device_id}" + self._description = description + + @property + def name(self) -> str: + """Return the name of the entity.""" + return ( + self.data[ATTR_NAME] if ATTR_NAME in self.data else self._description.name + ) + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + if self._device_id in self.coordinator.data: + self.data = self.coordinator.data[self._device_id] + else: + # device removed, remove entity + _LOGGER.debug( + "Entity %s was removed from the connector, cleaning up", self.entity_id + ) + entity_registry = er.async_get(self.hass) + if entity_registry.async_get(self.entity_id): + entity_registry.async_remove(self.entity_id) + + self.async_write_ha_state() + + @property + def device_info(self) -> DeviceInfo: + """Return info for device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, self._connector_id)}, + manufacturer="Elro", + model="K1 (SF40GA)", + name="Elro Connects K1 connector", + ) diff --git a/custom_components/elro_connects/info.md b/custom_components/elro_connects/info.md new file mode 100644 index 0000000..56392df --- /dev/null +++ b/custom_components/elro_connects/info.md @@ -0,0 +1,42 @@ +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]][license] + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] + +[![Community Forum][forum-shield]][forum] + +**This component will set up the following platforms.** + +Platform | Description +-- | -- +`siren` | Represents Elro Connects alarms as a siren. Turn the siren `ON` to test it. Turn it `OFF` to silence the (test) alarm. + +Other platforms (sensor e.g. sensor for battery level and signal strength are to be added later) + +{% if not installed %} + +## Installation + +1. Click install. +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Elro Connects". + +{% endif %} + +*** + +[integration_blueprint]: https://github.com/jbouwh/ha-elro/connects +[commits-shield]: https://img.shields.io/github/commit-activity/y/jbouwh/ha-elro/connects.svg?style=for-the-badge +[commits]: https://github.com/jbouwh/ha-elro/connects/commits/main +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[exampleimg]: example.png +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge +[forum]: https://community.home-assistant.io/ +[license]: https://github.com/jbouwh/ha-elro/connects/blob/main/LICENSE +[license-shield]: https://img.shields.io/github/license/jbouwh/ha-elro/connects.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/jbouwh/ha-elro/connects.svg?style=for-the-badge +[releases]: https://github.com/jbouwh/ha-elro/connects/releases +[user_profile]: https://github.com/jbouwh diff --git a/custom_components/elro_connects/manifest.json b/custom_components/elro_connects/manifest.json new file mode 100644 index 0000000..34ee1fb --- /dev/null +++ b/custom_components/elro_connects/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "elro_connects", + "name": "Elro Connects", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/elro_connects", + "requirements": ["lib-elro-connects==0.4.1"], + "codeowners": ["@jbouwh"], + "iot_class": "local_polling" +} diff --git a/custom_components/elro_connects/services.yaml b/custom_components/elro_connects/services.yaml new file mode 100644 index 0000000..ae9726d --- /dev/null +++ b/custom_components/elro_connects/services.yaml @@ -0,0 +1,5 @@ +# Describes the format for available Elro Connects services + +reload: + name: Reload + description: Reload Elro Connects. diff --git a/custom_components/elro_connects/siren.py b/custom_components/elro_connects/siren.py new file mode 100644 index 0000000..58043a4 --- /dev/null +++ b/custom_components/elro_connects/siren.py @@ -0,0 +1,141 @@ +"""The Elro Connects siren platform.""" +from __future__ import annotations + +import logging + +from elro.command import SILENCE_ALARM, TEST_ALARM +from elro.device import ( + ALARM_CO, + ALARM_FIRE, + ALARM_HEAT, + ALARM_SMOKE, + ALARM_WATER, + ATTR_DEVICE_STATE, + ATTR_DEVICE_TYPE, + STATE_SILENCE, + STATE_TEST_ALARM, + STATES_OFFLINE, + STATES_ON, +) + +from homeassistant.components.siren import SirenEntity, SirenEntityDescription +from homeassistant.components.siren.const import SirenEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_CONNECTOR_ID, DOMAIN +from .device import ElroConnectsEntity, ElroConnectsK1 + +_LOGGER = logging.getLogger(__name__) + +SIREN_DEVICE_TYPES = { + ALARM_CO: SirenEntityDescription( + key=ALARM_CO, + device_class="carbon_monoxide", + name="CO Alarm", + icon="mdi:molecule-co", + ), + ALARM_FIRE: SirenEntityDescription( + key=ALARM_FIRE, + device_class="smoke", + name="Fire Alarm", + icon="mdi:fire-alert", + ), + ALARM_HEAT: SirenEntityDescription( + key=ALARM_HEAT, + device_class="heat", + name="Heat Alarm", + icon="mdi:fire-alert", + ), + ALARM_SMOKE: SirenEntityDescription( + key=ALARM_SMOKE, + device_class="smoke", + name="Smoke Alarm", + icon="mdi:smoke", + ), + ALARM_WATER: SirenEntityDescription( + key=ALARM_WATER, + device_class="moisture", + name="Water Alarm", + icon="mid:water-alert", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + elro_connects_api: ElroConnectsK1 = hass.data[DOMAIN][config_entry.entry_id] + connector_id: str = config_entry.data[CONF_CONNECTOR_ID] + device_status: dict[int, dict] = elro_connects_api.coordinator.data + + async_add_entities( + [ + ElroConnectsSiren( + elro_connects_api, + connector_id, + device_id, + SIREN_DEVICE_TYPES[attributes[ATTR_DEVICE_TYPE]], + ) + for device_id, attributes in device_status.items() + if attributes[ATTR_DEVICE_TYPE] in SIREN_DEVICE_TYPES + ] + ) + + +class ElroConnectsSiren(ElroConnectsEntity, SirenEntity): + """Elro Connects Fire Alarm Entity.""" + + def __init__( + self, + elro_connects_api: ElroConnectsK1, + connector_id: str, + device_id: int, + description: SirenEntityDescription, + ) -> None: + """Initialize a Fire Alarm Entity.""" + self._device_id = device_id + self._elro_connects_api = elro_connects_api + self._attr_supported_features = ( + SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF + ) + ElroConnectsEntity.__init__( + self, + elro_connects_api.coordinator, + connector_id, + device_id, + description, + ) + + @property + def is_on(self) -> bool | None: + """Return true if device is on or none if the device is offline.""" + if not self.data or self.data[ATTR_DEVICE_STATE] in STATES_OFFLINE: + return None + return self.data[ATTR_DEVICE_STATE] in STATES_ON + + async def async_turn_on(self, **kwargs) -> None: + """Send a test alarm request.""" + _LOGGER.debug("Sending test alarm request for entity %s", self.entity_id) + await self._elro_connects_api.async_connect() + await self._elro_connects_api.async_process_command( + TEST_ALARM, device_ID=self._device_id + ) + + self.data[ATTR_DEVICE_STATE] = STATE_TEST_ALARM + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Send a silence alarm request.""" + _LOGGER.debug("Sending silence alarm request for entity %s", self.entity_id) + await self._elro_connects_api.async_connect() + await self._elro_connects_api.async_process_command( + SILENCE_ALARM, device_ID=self._device_id + ) + + self.data[ATTR_DEVICE_STATE] = STATE_SILENCE + self.async_write_ha_state() diff --git a/custom_components/elro_connects/strings.json b/custom_components/elro_connects/strings.json new file mode 100644 index 0000000..f3056d5 --- /dev/null +++ b/custom_components/elro_connects/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "data": { + "connector_id": "Connector ID", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + } + } +} diff --git a/custom_components/elro_connects/translations/en.json b/custom_components/elro_connects/translations/en.json new file mode 100644 index 0000000..b3f3309 --- /dev/null +++ b/custom_components/elro_connects/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "connector_id": "Connector ID", + "host": "Hostname or IP address", + "port": "Port" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/elro_connects/translations/nl.json b/custom_components/elro_connects/translations/nl.json new file mode 100644 index 0000000..d1e40f2 --- /dev/null +++ b/custom_components/elro_connects/translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al ingesteld" + }, + "error": { + "cannot_connect": "Maken van de verbinding mislukt", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "connector_id": "Connector ID", + "host": "Hostnaam of IP adres", + "port": "Poort" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "host": "Hostnaam of IP adres", + "port": "Poort" + } + } + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..b742124 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Elro Connects", + "hacs": "1.6.0", + "domains": ["siren"], + "homeassistant": "2022.5.0" +} diff --git a/tests/elro_connects/README.md b/tests/elro_connects/README.md new file mode 100644 index 0000000..8a8e1d2 --- /dev/null +++ b/tests/elro_connects/README.md @@ -0,0 +1,25 @@ +# Why? + +While tests aren't required to publish a custom component for Home Assistant, they will generally make development easier because good tests will expose when changes you want to make to the component logic will break expected functionality. Home Assistant uses [`pytest`](https://docs.pytest.org/en/latest/) for its tests, and the tests that have been included are modeled after tests that are written for core Home Assistant integrations. These tests pass with 100% coverage (unless something has changed ;) ) and have comments to help you understand the purpose of different parts of the test. + +# Getting Started + +To begin, it is recommended to create a virtual environment to install dependencies: + +```bash +python3 -m venv venv +source venv/bin/activate +``` + +You can then install the dependencies that will allow you to run tests: +`pip3 install -r requirements_test.txt.` + +This will install `lib-elro-connects`, `homeassistant`, `pytest`, and `pytest-homeassistant-custom-component`, a plugin which allows you to leverage helpers that are available in Home Assistant for core integration tests. + +# Useful commands + +Command | Description +------- | ----------- +`pytest tests/` | This will run all tests in `tests/` and tell you how many passed/failed +`pytest --durations=10 --cov-report term-missing --cov=custom_components.elro_connects` | This tells `pytest` that your target module to test is `custom_components.integration_blueprint` so that it can give you a [code coverage](https://en.wikipedia.org/wiki/Code_coverage) summary, including % of code that was executed and the line numbers of missed executions. +`pytest tests/elro_connects/test_init.py -k test_form` | Runs the `test_form` test function located in `tests/elro_connects/test_init.py` diff --git a/tests/elro_connects/__init__.py b/tests/elro_connects/__init__.py new file mode 100644 index 0000000..2fe99a8 --- /dev/null +++ b/tests/elro_connects/__init__.py @@ -0,0 +1 @@ +"""Tests for the Elro Connects integration.""" diff --git a/tests/elro_connects/conftest.py b/tests/elro_connects/conftest.py new file mode 100644 index 0000000..79689c4 --- /dev/null +++ b/tests/elro_connects/conftest.py @@ -0,0 +1,72 @@ +"""Fixtures for testing the Elro Connects integration.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.elro_connects.const import CONF_CONNECTOR_ID, DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_k1_connector() -> dict[AsyncMock]: + """Mock the Elro K1 connector class.""" + with patch( + "homeassistant.components.elro_connects.device.ElroConnectsK1.async_connect", + AsyncMock(), + ) as mock_connect, patch( + "homeassistant.components.elro_connects.device.ElroConnectsK1.async_disconnect", + AsyncMock(), + ) as mock_disconnect, patch( + "homeassistant.components.elro_connects.device.ElroConnectsK1.async_configure", + AsyncMock(), + ) as mock_configure, patch( + "homeassistant.components.elro_connects.device.ElroConnectsK1.async_process_command", + AsyncMock(return_value={}), + ) as mock_result: + yield { + "connect": mock_connect, + "disconnect": mock_disconnect, + "configure": mock_configure, + "result": mock_result, + } + + +@pytest.fixture +def mock_k1_api() -> dict[AsyncMock]: + """Mock the Elro K1 API.""" + with patch("elro.api.K1.async_connect", AsyncMock(),) as mock_connect, patch( + "elro.api.K1.async_disconnect", + AsyncMock(), + ) as mock_disconnect, patch( + "elro.api.K1.async_configure", + AsyncMock(), + ) as mock_configure, patch( + "elro.api.K1.async_process_command", + AsyncMock(return_value={}), + ) as mock_result: + yield { + "connect": mock_connect, + "disconnect": mock_disconnect, + "configure": mock_configure, + "result": mock_result, + } + + +@pytest.fixture +def mock_entry(hass: HomeAssistant) -> ConfigEntry: + """Mock a Elro Connects config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_CONNECTOR_ID: "ST_deadbeef0000", + CONF_PORT: 1025, + }, + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/elro_connects/requirements_dev.txt b/tests/elro_connects/requirements_dev.txt new file mode 100644 index 0000000..0c882b0 --- /dev/null +++ b/tests/elro_connects/requirements_dev.txt @@ -0,0 +1,2 @@ +homeassistant +lib-elro-connects \ No newline at end of file diff --git a/tests/elro_connects/requirements_test.txt b/tests/elro_connects/requirements_test.txt new file mode 100644 index 0000000..db98615 --- /dev/null +++ b/tests/elro_connects/requirements_test.txt @@ -0,0 +1,2 @@ +pytest-homeassistant-custom-component==0.4.0 +lib-elro-connects==0.4.1 diff --git a/tests/elro_connects/test_common.py b/tests/elro_connects/test_common.py new file mode 100644 index 0000000..866e441 --- /dev/null +++ b/tests/elro_connects/test_common.py @@ -0,0 +1,56 @@ +"""Helpers for testing the Elro Connects integration.""" + +MOCK_DEVICE_STATUS_DATA = { + 1: { + "device_type": "FIRE_ALARM", + "signal": 3, + "battery": 100, + "device_state": "NORMAL", + "device_status_data": { + "cmdId": 19, + "device_ID": 1, + "device_name": "0013", + "device_status": "0364AAFF", + }, + "name": "Beganegrond", + }, + 2: { + "device_type": "FIRE_ALARM", + "signal": 4, + "battery": 75, + "device_state": "ALARM", + "device_status_data": { + "cmdId": 19, + "device_ID": 2, + "device_name": "0013", + "device_status": "044B55FF", + }, + "name": "Eerste etage", + }, + 4: { + "device_type": "FIRE_ALARM", + "signal": 1, + "battery": 5, + "device_state": "UNKNOWN", + "device_status_data": { + "cmdId": 19, + "device_ID": 4, + "device_name": "0013", + "device_status": "0105FEFF", + }, + "name": "Zolder", + }, + 5: { + "device_type": "CO_ALARM", + "signal": 255, + "battery": 255, + "device_state": "OFFLINE", + "device_status_data": { + "cmdId": 19, + "device_ID": 5, + "device_name": "2008", + "device_status": "FFFFFFFF", + }, + "name": "Corner", + }, +} diff --git a/tests/elro_connects/test_config_flow.py b/tests/elro_connects/test_config_flow.py new file mode 100644 index 0000000..e3b0d1a --- /dev/null +++ b/tests/elro_connects/test_config_flow.py @@ -0,0 +1,186 @@ +"""Test the Elro Connects config flow.""" +from unittest.mock import AsyncMock, patch + +from elro.api import K1 +import pytest + +from homeassistant import config_entries +from homeassistant.components.elro_connects.const import CONF_CONNECTOR_ID, DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.setup import async_setup_component + + +async def test_form(hass: HomeAssistant, mock_k1_api: dict[AsyncMock]) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.elro_connects.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "connector_id": "ST_deadbeef0000", + "port": 1025, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Elro Connects K1 Connector" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_CONNECTOR_ID: "ST_deadbeef0000", + CONF_PORT: 1025, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "side_effect, error", + [ + (K1.K1ConnectionError, "cannot_connect"), + (Exception("Some unhandled error"), "unknown"), + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_k1_api: dict[AsyncMock], + side_effect: Exception, + error: str, +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_k1_api["connect"].side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_CONNECTOR_ID: "ST_deadbeef0000", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": error} + + +async def test_already_setup(hass: HomeAssistant, mock_k1_api: dict[AsyncMock]) -> None: + """Test we cannot create a duplicate setup.""" + # Setup the existing unique config entry + await test_form(hass, mock_k1_api) + + # Now assert the entry creation is aborted if we try + # to create an entry with the same unique device_id + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.elro_connects.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.2", + CONF_CONNECTOR_ID: "ST_deadbeef0000", + CONF_PORT: 1024, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + + +async def test_update_options( + hass: HomeAssistant, + mock_k1_connector: dict[AsyncMock], + mock_k1_api: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test we can update the configuration.""" + # Setup the existing config entry + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + # Start config flow + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # Change interval, IP address and port + with patch( + "homeassistant.components.elro_connects.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.1.1.2", + CONF_PORT: 1024, + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + + assert config_entry.data.get(CONF_HOST) == "1.1.1.2" + assert config_entry.data.get(CONF_CONNECTOR_ID) == "ST_deadbeef0000" + assert config_entry.data.get(CONF_PORT) == 1024 + + +@pytest.mark.parametrize( + "side_effect", + [ + (K1.K1ConnectionError,), + (Exception("Some unhandled error"),), + ], +) +async def test_update_options_cannot_connect_handling( + hass: HomeAssistant, mock_k1_api: dict[AsyncMock], side_effect: Exception +) -> None: + """Test cannot connect when updating the configuration.""" + # Setup the existing config entry + await test_form(hass, mock_k1_api) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + # Start config flow + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # Change interval, IP address and port + mock_k1_api["connect"].side_effect = side_effect + with patch( + "homeassistant.components.elro_connects.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.1.1.2", + CONF_PORT: 1024, + }, + ) + assert result["type"] == RESULT_TYPE_FORM diff --git a/tests/elro_connects/test_init.py b/tests/elro_connects/test_init.py new file mode 100644 index 0000000..474d47c --- /dev/null +++ b/tests/elro_connects/test_init.py @@ -0,0 +1,116 @@ +"""Test the Elro Connects setup.""" + +import copy +from datetime import timedelta +from unittest.mock import AsyncMock + +from elro.api import K1 +import pytest + +from homeassistant.components.elro_connects.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from .test_common import MOCK_DEVICE_STATUS_DATA + +from tests.common import async_fire_time_changed + + +async def test_setup_integration_no_data( + hass: HomeAssistant, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test we can setup an empty integration.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +async def test_setup_integration_update_fail( + hass: HomeAssistant, + caplog, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test if an update can fail with warnings.""" + mock_k1_connector["result"].side_effect = K1.K1ConnectionError + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert ( + "elro_connects integration not ready yet: K1 connection error; Retrying in background" + in caplog.text + ) + + +async def test_setup_integration_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test if an unknown backend error throws.""" + mock_k1_connector["result"].side_effect = Exception + with pytest.raises(Exception): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert ( + "elro_connects integration not ready yet: K1 connection error; Retrying in background" + in caplog.text + ) + + +async def test_setup_integration_with_data( + hass: HomeAssistant, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test we can setup the integration with some data.""" + mock_k1_connector["result"].return_value = MOCK_DEVICE_STATUS_DATA + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +async def test_configure_platforms_dynamically( + hass: HomeAssistant, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test we can setup and tear down platforms dynamically.""" + # Updated status holds device info for device [1,2,4] + updated_status_data = copy.deepcopy(MOCK_DEVICE_STATUS_DATA) + # Initial status holds device info for device [1,2] + initial_status_data = copy.deepcopy(updated_status_data) + initial_status_data.pop(4) + + # setup integration with 2 siren entities + mock_k1_connector["result"].return_value = initial_status_data + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert hass.states.get("siren.beganegrond") is not None + assert hass.states.get("siren.eerste_etage") is not None + assert hass.states.get("siren.zolder") is None + + # Simulate a dynamic discovery update resulting in 3 siren entities + mock_k1_connector["result"].return_value = updated_status_data + time = dt.now() + timedelta(seconds=30) + async_fire_time_changed(hass, time) + # await coordinator.async_request_refresh() + await hass.async_block_till_done() + + assert hass.states.get("siren.beganegrond") is not None + assert hass.states.get("siren.eerste_etage") is not None + assert hass.states.get("siren.zolder") is not None + + # Remove device 1 from api data, entity should be removed + updated_status_data.pop(1) + + mock_k1_connector["result"].return_value = updated_status_data + time = time + timedelta(seconds=30) + async_fire_time_changed(hass, time) + await hass.async_block_till_done() + + assert hass.states.get("siren.beganegrond") is None + assert hass.states.get("siren.eerste_etage") is not None + assert hass.states.get("siren.zolder") is not None diff --git a/tests/elro_connects/test_siren.py b/tests/elro_connects/test_siren.py new file mode 100644 index 0000000..5bad589 --- /dev/null +++ b/tests/elro_connects/test_siren.py @@ -0,0 +1,136 @@ +"""Test the Elro Connects siren platform.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from elro.command import Command +import pytest + +from homeassistant.components import siren +from homeassistant.components.elro_connects.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .test_common import MOCK_DEVICE_STATUS_DATA + + +@pytest.mark.parametrize( + "entity_id,name,state,icon,device_class", + [ + ( + "siren.beganegrond", + "Beganegrond", + STATE_OFF, + "mdi:fire-alert", + "smoke", + ), + ( + "siren.eerste_etage", + "Eerste etage", + STATE_ON, + "mdi:fire-alert", + "smoke", + ), + ( + "siren.zolder", + "Zolder", + STATE_OFF, + "mdi:fire-alert", + "smoke", + ), + ( + "siren.corner", + "Corner", + STATE_UNKNOWN, + "mdi:molecule-co", + "carbon_monoxide", + ), + ], +) +async def test_setup_integration_with_siren_platform( + hass: HomeAssistant, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, + entity_id: str, + name: str, + state: str, + icon: str, + device_class: str, +) -> None: + """Test we can setup the integration with the siren platform.""" + mock_k1_connector["result"].return_value = MOCK_DEVICE_STATUS_DATA + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Check entity setup from connector data + entity = hass.states.get(entity_id) + attributes = entity.attributes + + assert entity.state == state + assert attributes["friendly_name"] == name + assert attributes["icon"] == icon + assert attributes["device_class"] == device_class + + +async def test_alarm_testing( + hass: HomeAssistant, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test we can start a test alarm and silence it.""" + entity_id = "siren.beganegrond" + mock_k1_connector["result"].return_value = MOCK_DEVICE_STATUS_DATA + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity.state == STATE_OFF + + # Turn siren on with test signal + mock_k1_connector["result"].reset_mock() + await hass.services.async_call( + siren.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + entity = hass.states.get(entity_id) + assert entity.state == STATE_ON + assert ( + mock_k1_connector["result"].call_args[0][0]["cmd_id"] + == Command.EQUIPMENT_CONTROL + ) + assert ( + mock_k1_connector["result"].call_args[0][0]["additional_attributes"][ + "device_status" + ] + == "17000000" + ) + assert mock_k1_connector["result"].call_args[1] == {"device_ID": 1} + + # Turn siren off with silence command + mock_k1_connector["result"].reset_mock() + await hass.services.async_call( + siren.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + entity = hass.states.get(entity_id) + assert entity.state == STATE_OFF + assert ( + mock_k1_connector["result"].call_args[0][0]["cmd_id"] + == Command.EQUIPMENT_CONTROL + ) + assert ( + mock_k1_connector["result"].call_args[0][0]["additional_attributes"][ + "device_status" + ] + == "00000000" + ) + assert mock_k1_connector["result"].call_args[1] == {"device_ID": 1}