From 40989ad8fd0c8f9737f53595c495941dc7b97834 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 4 Jan 2024 17:13:43 +0100 Subject: [PATCH] Add socket support (#45) * Add socket support * Revert removal version file --- custom_components/elro_connects/__init__.py | 2 +- custom_components/elro_connects/manifest.json | 4 +- custom_components/elro_connects/strings.json | 5 + custom_components/elro_connects/switch.py | 119 ++++++++++++++++++ .../elro_connects/translations/de.json | 5 + .../elro_connects/translations/en.json | 5 + .../elro_connects/translations/es.json | 5 + .../elro_connects/translations/fr.json | 5 + .../elro_connects/translations/nl.json | 5 + .../elro_connects/translations/pt.json | 5 + .../elro_connects/translations/uk.json | 5 + requirements_dev.txt | 2 +- requirements_test.txt | 2 +- setup.py | 9 +- tests/test_common.py | 38 ++++++ tests/test_init.py | 7 ++ tests/test_switch.py | 117 +++++++++++++++++ version | 2 +- 18 files changed, 331 insertions(+), 11 deletions(-) create mode 100644 custom_components/elro_connects/switch.py create mode 100644 tests/test_switch.py diff --git a/custom_components/elro_connects/__init__.py b/custom_components/elro_connects/__init__.py index bc8aa4a..3dd5ef8 100644 --- a/custom_components/elro_connects/__init__.py +++ b/custom_components/elro_connects/__init__.py @@ -13,7 +13,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SIREN] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SIREN, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/custom_components/elro_connects/manifest.json b/custom_components/elro_connects/manifest.json index b390782..77f1427 100644 --- a/custom_components/elro_connects/manifest.json +++ b/custom_components/elro_connects/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "issue_tracker": "https://github.com/jbouwh/ha-elro-connects/issues", - "requirements": ["lib-elro-connects==0.5.4.1"], - "version": "v0.2.1-2" + "requirements": ["lib-elro-connects==0.6.0.1"], + "version": "v0.2.3" } diff --git a/custom_components/elro_connects/strings.json b/custom_components/elro_connects/strings.json index bfe9d56..86e89e0 100644 --- a/custom_components/elro_connects/strings.json +++ b/custom_components/elro_connects/strings.json @@ -72,6 +72,11 @@ "alarm_water": { "name": "Water Alarm" } + }, + "switch": { + "socket": { + "name": "Socket" + } } } } diff --git a/custom_components/elro_connects/switch.py b/custom_components/elro_connects/switch.py new file mode 100644 index 0000000..9e95ac3 --- /dev/null +++ b/custom_components/elro_connects/switch.py @@ -0,0 +1,119 @@ +"""The Elro Connects switch platform.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from elro.command import SOCKET_OFF, SOCKET_ON, CommandAttributes +from elro.device import ( + ATTR_DEVICE_STATE, + ATTR_DEVICE_VALUE, + DEVICE_VALUE_OFF, + DEVICE_VALUE_ON, + SOCKET, + STATES_OFFLINE, +) + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .device import ElroConnectsEntity, ElroConnectsK1 +from .helpers import async_set_up_discovery_helper + + +@dataclass +class ElroSwitchEntityDescription(SwitchEntityDescription): + """A class that describes elro siren entities.""" + + turn_on: CommandAttributes | None = None + turn_off: CommandAttributes | None = None + + +_LOGGER = logging.getLogger(__name__) + +SWITCH_DEVICE_TYPES = { + SOCKET: ElroSwitchEntityDescription( + key=SOCKET, + translation_key="socket", + device_class=SwitchDeviceClass.OUTLET, + turn_on=SOCKET_ON, + turn_off=SOCKET_OFF, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + current: set[int] = set() + + async_set_up_discovery_helper( + hass, + ElroConnectsSwitch, + config_entry, + current, + SWITCH_DEVICE_TYPES, + async_add_entities, + ) + + +class ElroConnectsSwitch(ElroConnectsEntity, SwitchEntity): + """Elro Connects Fire Alarm Entity.""" + + def __init__( + self, + elro_connects_api: ElroConnectsK1, + entry: ConfigEntry, + device_id: int, + description: ElroSwitchEntityDescription, + ) -> None: + """Initialize a Fire Alarm Entity.""" + self._attr_has_entity_name = True + self._device_id = device_id + self._elro_connects_api = elro_connects_api + self._description = description + ElroConnectsEntity.__init__( + self, + elro_connects_api, + entry, + 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 + if self.data[ATTR_DEVICE_VALUE] not in (DEVICE_VALUE_OFF, DEVICE_VALUE_ON): + return None + return self.data[ATTR_DEVICE_VALUE] == DEVICE_VALUE_ON + + async def async_turn_on(self, **kwargs) -> None: + """Turn switch on.""" + _LOGGER.debug("Sending turn_on request for entity %s", self.entity_id) + await self._elro_connects_api.async_command( + self._description.turn_on, device_ID=self._device_id + ) + + self.data[ATTR_DEVICE_VALUE] = DEVICE_VALUE_ON + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn switch off.""" + _LOGGER.debug("Sending turn_off request for entity %s", self.entity_id) + await self._elro_connects_api.async_command( + self._description.turn_off, device_ID=self._device_id + ) + + self.data[ATTR_DEVICE_VALUE] = DEVICE_VALUE_OFF + self.async_write_ha_state() diff --git a/custom_components/elro_connects/translations/de.json b/custom_components/elro_connects/translations/de.json index fbd9ed7..71db564 100644 --- a/custom_components/elro_connects/translations/de.json +++ b/custom_components/elro_connects/translations/de.json @@ -72,6 +72,11 @@ "alarm_water": { "name": "Wasseralarm" } + }, + "switch": { + "socket": { + "name": "Wandsteckdose" + } } } } diff --git a/custom_components/elro_connects/translations/en.json b/custom_components/elro_connects/translations/en.json index 4f24756..53febbb 100644 --- a/custom_components/elro_connects/translations/en.json +++ b/custom_components/elro_connects/translations/en.json @@ -72,6 +72,11 @@ "alarm_water": { "name": "Water Alarm" } + }, + "switch": { + "socket": { + "name": "Socket" + } } } } diff --git a/custom_components/elro_connects/translations/es.json b/custom_components/elro_connects/translations/es.json index 64f7016..de06805 100644 --- a/custom_components/elro_connects/translations/es.json +++ b/custom_components/elro_connects/translations/es.json @@ -72,6 +72,11 @@ "alarm_water": { "name": "Alarma de agua" } + }, + "switch": { + "socket": { + "name": "Toma de pared" + } } } } diff --git a/custom_components/elro_connects/translations/fr.json b/custom_components/elro_connects/translations/fr.json index 697b532..7f6744d 100644 --- a/custom_components/elro_connects/translations/fr.json +++ b/custom_components/elro_connects/translations/fr.json @@ -72,6 +72,11 @@ "alarm_water": { "name": "Alarme d'eau" } + }, + "switch": { + "socket": { + "name": "La prise électrique" + } } } } diff --git a/custom_components/elro_connects/translations/nl.json b/custom_components/elro_connects/translations/nl.json index b5b461d..74515fd 100644 --- a/custom_components/elro_connects/translations/nl.json +++ b/custom_components/elro_connects/translations/nl.json @@ -72,6 +72,11 @@ "alarm_water": { "name": "Wateralarm" } + }, + "switch": { + "socket": { + "name": "Stopcontact" + } } } } diff --git a/custom_components/elro_connects/translations/pt.json b/custom_components/elro_connects/translations/pt.json index c8021a0..cda23bb 100644 --- a/custom_components/elro_connects/translations/pt.json +++ b/custom_components/elro_connects/translations/pt.json @@ -72,6 +72,11 @@ "alarm_water": { "name": "Alarme de água" } + }, + "switch": { + "socket": { + "name": "Tomada de parede" + } } } } diff --git a/custom_components/elro_connects/translations/uk.json b/custom_components/elro_connects/translations/uk.json index 39ffd12..b84a8ee 100644 --- a/custom_components/elro_connects/translations/uk.json +++ b/custom_components/elro_connects/translations/uk.json @@ -72,6 +72,11 @@ "alarm_water": { "name": "Сигналізація води" } + }, + "switch": { + "socket": { + "name": "Розетка" + } } } } diff --git a/requirements_dev.txt b/requirements_dev.txt index 3aa6542..7f32fb9 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ pre-commit==3.3.1 -lib-elro-connects==0.5.4.1 +lib-elro-connects==0.6.0.1 homeassistant==2023.7.2 pytest-homeassistant-custom-component==0.13.44 diff --git a/requirements_test.txt b/requirements_test.txt index 7129928..67ad684 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -24,7 +24,7 @@ fnvhash==0.1.0 lru-dict==1.2.0 -lib-elro-connects==0.5.4.1 +lib-elro-connects==0.6.0.1 pytest-homeassistant-custom-component==0.13.44 diff --git a/setup.py b/setup.py index 5bb4f93..0826138 100644 --- a/setup.py +++ b/setup.py @@ -6,12 +6,12 @@ requirements = [ "sqlalchemy", ] -with open("requirements_test.txt") as f: +with open("requirements_test.txt", encoding="utf-8") as f: for line in f: if "txt" not in line and "#" not in line: requirements.append(line) -with open("version") as f: +with open("version", encoding="utf-8") as f: __version__ = f.read() setup( @@ -26,13 +26,12 @@ author_email="jan@jbsoft.nl", description="Add Elro Connects alarm devices to Home Assistant", classifiers=[ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Framework :: Pytest", - "Intended Audience :: Developers", + "Intended Audience :: Information Technology", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3.9", - "Topic :: Software Development :: Testing", ], entry_points={ "pytest11": ["homeassistant = pytest_homeassistant_custom_component.plugins"] diff --git a/tests/test_common.py b/tests/test_common.py index 91bb6ad..b265d35 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -12,6 +12,8 @@ "device_name": "0013", "device_status": "0364AAFF", }, + "device_value": "0xff", + "device_value_data": 255, "name": "Beganegrond", }, 2: { @@ -25,6 +27,8 @@ "device_name": "0013", "device_status": "044B55FF", }, + "device_value": "0xff", + "device_value_data": 255, "name": "Eerste etage", }, 4: { @@ -38,6 +42,8 @@ "device_name": "0013", "device_status": "0105FEFF", }, + "device_value": "0xff", + "device_value_data": 255, "name": "Zolder", }, 5: { @@ -51,11 +57,43 @@ "device_name": "2008", "device_status": "FFFFFFFF", }, + "device_value": "0xff", + "device_value_data": 255, "name": "Corner", }, 6: { "name": "Device with unknown state", }, + 7: { + "device_type": "SOCKET", + "signal": 255, + "battery": 255, + "device_state": "NORMAL", + "device_status_data": { + "cmdId": 19, + "device_ID": 5, + "device_name": "1200", + "device_status": "04FF0100", + }, + "device_value": "off", + "device_value_data": 1, + "name": "Wall switch off", + }, + 8: { + "device_type": "SOCKET", + "signal": 255, + "battery": 255, + "device_state": "NORMAL", + "device_status_data": { + "cmdId": 19, + "device_ID": 5, + "device_name": "1200", + "device_status": "04FF0101", + }, + "device_value": "on", + "device_value_data": 1, + "name": "Wall switch on", + }, } diff --git a/tests/test_init.py b/tests/test_init.py index 58ef09f..14f7462 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -11,6 +11,7 @@ from custom_components.elro_connects import async_remove_config_entry_device from custom_components.elro_connects.const import DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import format_mac @@ -107,6 +108,8 @@ async def test_configure_platforms_dynamically( assert hass.states.get("siren.beganegrond_fire_alarm") is not None assert hass.states.get("siren.eerste_etage_fire_alarm") is not None assert hass.states.get("siren.zolder_fire_alarm") is None + assert hass.states.get("switch.wall_switch_off_socket") is not None + assert hass.states.get("switch.wall_switch_on_socket") is not None # Simulate a dynamic discovery update resulting in 3 siren entities mock_k1_connector["result"].return_value = updated_status_data @@ -118,6 +121,8 @@ async def test_configure_platforms_dynamically( assert hass.states.get("siren.beganegrond_fire_alarm") is not None assert hass.states.get("siren.eerste_etage_fire_alarm") is not None assert hass.states.get("siren.zolder_fire_alarm") is not None + assert hass.states.get("switch.wall_switch_off_socket") is not None + assert hass.states.get("switch.wall_switch_on_socket") is not None # Remove device 1 from api data, entity should appear offline with an unknown state updated_status_data.pop(1) @@ -130,6 +135,8 @@ async def test_configure_platforms_dynamically( assert hass.states.get("siren.beganegrond_fire_alarm").state == "off" assert hass.states.get("siren.eerste_etage_fire_alarm") is not None assert hass.states.get("siren.zolder_fire_alarm") is not None + assert hass.states.get("switch.wall_switch_off_socket").state == STATE_OFF + assert hass.states.get("switch.wall_switch_on_socket").state == STATE_ON async def test_remove_device_from_config_entry( diff --git a/tests/test_switch.py b/tests/test_switch.py new file mode 100644 index 0000000..5bb266e --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,117 @@ +"""Test the Elro Connects switch platform.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from elro.command import Command +import pytest + +from custom_components.elro_connects.const import DOMAIN +from homeassistant.components import switch +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +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,device_class", + [ + ( + "switch.wall_switch_off_socket", + "Wall switch off Socket", + STATE_OFF, + "outlet", + ), + ( + "switch.wall_switch_on_socket", + "Wall switch on Socket", + STATE_ON, + "outlet", + ), + ], +) +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, + device_class: str, +) -> None: + """Test we can setup the integration with the siren platform.""" + mock_k1_connector["result"].return_value = MOCK_DEVICE_STATUS_DATA + await hass.config_entries.async_setup(mock_entry.entry_id) + 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["device_class"] == device_class + + +async def test_socket_testing( + hass: HomeAssistant, + mock_k1_connector: dict[AsyncMock], + mock_entry: ConfigEntry, +) -> None: + """Test we turn on a socket.""" + entity_id = "switch.wall_switch_off_socket" + 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( + switch.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" + ] + == "01010000" + ) + assert mock_k1_connector["result"].call_args[1] == {"device_ID": 7} + + # Turn the socket off + mock_k1_connector["result"].reset_mock() + await hass.services.async_call( + switch.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" + ] + == "01000000" + ) + assert mock_k1_connector["result"].call_args[1] == {"device_ID": 7} diff --git a/version b/version index 0c62199..373f8c6 100644 --- a/version +++ b/version @@ -1 +1 @@ -0.2.1 +0.2.3 \ No newline at end of file