From e0c363aee6cfa73ed256596dd6a1d38be46dc569 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 9 Aug 2022 17:19:33 +0200 Subject: [PATCH] Add name update support (#11) When a device name is updated, this update will be propagated to Elro Connects app. Note: The name length is limited to 15 characters. --- custom_components/elro_connects/__init__.py | 2 +- custom_components/elro_connects/device.py | 87 ++++++++++++++++--- custom_components/elro_connects/manifest.json | 2 +- custom_components/elro_connects/siren.py | 6 +- tests/test_init.py | 67 +++++++++++++- 5 files changed, 147 insertions(+), 17 deletions(-) diff --git a/custom_components/elro_connects/__init__.py b/custom_components/elro_connects/__init__.py index 2cd71ac..1094a43 100644 --- a/custom_components/elro_connects/__init__.py +++ b/custom_components/elro_connects/__init__.py @@ -36,7 +36,7 @@ async def _async_update_data() -> dict[int, dict]: for device_id, state_base in coordinator_update.items(): state_base[ATTR_DEVICE_STATE] = STATE_UNKNOWN try: - await hass.async_create_task(elro_connects_api.async_update()) + await elro_connects_api.async_update() device_update = copy.deepcopy(elro_connects_api.data) for device_id, device_data in device_update.items(): if ATTR_DEVICE_STATE not in device_data: diff --git a/custom_components/elro_connects/device.py b/custom_components/elro_connects/device.py index 486be2d..8fc8b03 100644 --- a/custom_components/elro_connects/device.py +++ b/custom_components/elro_connects/device.py @@ -1,10 +1,17 @@ """Elro Connects K1 device communication.""" from __future__ import annotations +import asyncio import logging +from typing import Any from elro.api import K1 -from elro.command import GET_ALL_EQUIPMENT_STATUS, GET_DEVICE_NAMES +from elro.command import ( + GET_ALL_EQUIPMENT_STATUS, + GET_DEVICE_NAMES, + SET_DEVICE_NAME, + CommandAttributes, +) from elro.device import ( ALARM_CO, ALARM_FIRE, @@ -17,8 +24,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -50,39 +58,97 @@ def __init__( ) -> None: """Initialize the K1 connector.""" self._coordinator = coordinator + self.hass = coordinator.hass + self._entry = entry + self._data: dict[int, dict] = {} + self._api_lock = asyncio.Lock() self._connector_id = entry.data[CONF_CONNECTOR_ID] self._retry_count = 0 + self._device_registry_updated = coordinator.hass.bus.async_listen( + EVENT_DEVICE_REGISTRY_UPDATED, self._async_device_updated + ) + K1.__init__( self, entry.data[CONF_HOST], entry.data[CONF_CONNECTOR_ID], entry.data[CONF_PORT], + entry.data.get(CONF_API_KEY), ) + async def _async_device_updated(self, event: Event) -> None: + """Propagate name changes though the connector.""" + if ( + event.data["action"] != "update" + or "name_by_user" not in event.data["changes"] + ): + # Ignore "create" action and other changes + return + + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get(event.data["device_id"]) + device_unique_id: str = device_entry.identifiers.copy().pop()[1] + device_id_str = device_unique_id[len(self.connector_id) + 1 :] + if self._entry.entry_id not in device_entry.config_entries or not device_id_str: + # Not a valid device name or not a related entry + return + device_id = int(device_id_str) + if device_id not in self.data: + # the device is not in the coordinator data hence we cannot update it + return False + + if device_entry.name != device_entry.name_by_user: + await self.async_command( + SET_DEVICE_NAME, + device_ID=device_id, + device_name=device_entry.name_by_user[:15] + if len(device_entry.name_by_user) > 15 + else device_entry.name_by_user, + ) + async def async_update(self) -> None: """Synchronize with the K1 connector.""" - new_data: dict[int, dict] = {} - try: + + async def _async_update() -> None: + new_data: dict[int, dict] = {} update_status = await self.async_process_command(GET_ALL_EQUIPMENT_STATUS) new_data = update_status update_names = await self.async_process_command(GET_DEVICE_NAMES) update_state_data(new_data, update_names) self._retry_count = 0 self._data = new_data + + try: + async with self._api_lock: + await self.hass.async_add_job(_async_update()) except K1.K1ConnectionError as err: self._retry_count += 1 if not self._data or self._retry_count >= MAX_RETRIES: raise K1.K1ConnectionError(err) from err + async def async_command( + self, + command: CommandAttributes, + **argv: int | str, + ) -> dict[int, dict[str, Any]] | None: + """Execute a synchronized command through the K1 connector.""" + async with self._api_lock: + return self.hass.async_add_job(self.async_process_command(command, **argv)) + 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], entry.data.get(CONF_API_KEY) - ) + async with self._api_lock: + hass.async_create_task( + self.async_configure( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data.get(CONF_API_KEY), + ) + ) @property def data(self) -> dict[int, dict]: @@ -143,12 +209,12 @@ def device_info(self) -> DeviceInfo: """Return info for device registry.""" # connector device_registry = dr.async_get(self.hass) - k1_device = device_registry.async_get_or_create( + device_registry.async_get_or_create( model="K1 (SF40GA)", config_entry_id=self._entry.entry_id, identifiers={(DOMAIN, self._connector_id)}, manufacturer="Elro", - name="Elro Connects K1 connector", + name=f"Elro Connects K1 {self._connector_id}", ) # sub device device_type = self.data[ATTR_DEVICE_TYPE] @@ -159,6 +225,7 @@ def device_info(self) -> DeviceInfo: if device_type in DEVICE_MODELS else device_type, name=self.name, - via_device=(DOMAIN, k1_device.id), + # Link to K1 connector + via_device=(DOMAIN, self._connector_id), ) return device_info diff --git a/custom_components/elro_connects/manifest.json b/custom_components/elro_connects/manifest.json index f479281..e94cbb2 100644 --- a/custom_components/elro_connects/manifest.json +++ b/custom_components/elro_connects/manifest.json @@ -7,5 +7,5 @@ "requirements": ["lib-elro-connects==0.5.3"], "codeowners": ["@jbouwh"], "iot_class": "local_polling", - "version": "0.1.8" + "version": "0.1.9" } diff --git a/custom_components/elro_connects/siren.py b/custom_components/elro_connects/siren.py index b1f59be..860c9aa 100644 --- a/custom_components/elro_connects/siren.py +++ b/custom_components/elro_connects/siren.py @@ -141,8 +141,7 @@ def is_on(self) -> bool | None: 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( + await self._elro_connects_api.async_command( self._description.test_alarm, device_ID=self._device_id ) @@ -152,8 +151,7 @@ async def async_turn_on(self, **kwargs) -> None: 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( + await self._elro_connects_api.async_command( self._description.silence_alarm, device_ID=self._device_id ) diff --git a/tests/test_init.py b/tests/test_init.py index 64f091c..c4f507a 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -152,7 +152,7 @@ async def test_remove_device_from_config_entry( 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() + # Wait for the update to be processed await hass.async_block_till_done() await hass.async_block_till_done() @@ -178,3 +178,68 @@ async def test_remove_device_from_config_entry( ) assert device_entry assert not await async_remove_config_entry_device(hass, mock_entry, device_entry) + + +async def test_update_device_name( + hass: HomeAssistant, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test updating the name of the device through the K1 connector.""" + + # Initial status holds device info for device [1,2,4] + initial_status_data = copy.deepcopy(MOCK_DEVICE_STATUS_DATA) + mock_k1_connector["result"].return_value = initial_status_data + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + connector_id = hass.data[DOMAIN][mock_entry.entry_id].connector_id + device_registry = dr.async_get(hass) + # Test updating the device name for siren 4 will work + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"{connector_id}_4")} + ) + assert device_entry + + # update the name + mock_k1_connector["result"].reset_mock() + device_registry.async_update_device( + device_id=device_entry.id, name_by_user="Some long new name" + ) + await hass.async_block_till_done() + + # Check the new name was set (max length 15) + assert mock_k1_connector["result"].call_count == 1 + assert mock_k1_connector["result"].mock_calls[0][2] == { + "device_ID": 4, + "device_name": "Some long new n", + } + + # Check the name was not set if the device is not in the coordinator + updated_status_data = copy.deepcopy(MOCK_DEVICE_STATUS_DATA) + updated_status_data.pop(4) + mock_k1_connector["result"].return_value = updated_status_data + time = dt.now() + timedelta(seconds=30) + async_fire_time_changed(hass, time) + # Wait for the update to be processed + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_k1_connector["result"].reset_mock() + device_registry.async_update_device( + device_id=device_entry.id, name_by_user="Name update for non existent device" + ) + await hass.async_block_till_done() + assert mock_k1_connector["result"].call_count == 0 + + # update the K1 connector name + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"{connector_id}")} + ) + assert device_entry + mock_k1_connector["result"].reset_mock() + device_registry.async_update_device( + device_id=device_entry.id, name_by_user="Some long new name" + ) + await hass.async_block_till_done() + + assert mock_k1_connector["result"].call_count == 0