From dfaf4098aaea2908148517c0372cbce65a89f711 Mon Sep 17 00:00:00 2001 From: Christopher Dohmen Date: Fri, 2 Aug 2024 18:44:34 -0400 Subject: [PATCH] Transformer fix (#98) Fix issues for landscape-transformer * Fix an issue where a sensor could return the wrong type which would produce a UHE in HA * Update the correct state for the switch * Fix switch duplicate issue Sem-Ver: bugfix --- custom_components/hubspace/sensor.py | 10 +- custom_components/hubspace/switch.py | 21 +- tests/device_dumps/transformer.json | 538 +++++++++++++++++++++++++++ tests/test_sensor.py | 27 +- tests/test_switch.py | 67 ++++ 5 files changed, 644 insertions(+), 19 deletions(-) create mode 100644 tests/device_dumps/transformer.json create mode 100644 tests/test_switch.py diff --git a/custom_components/hubspace/sensor.py b/custom_components/hubspace/sensor.py index 30e8a20..61e3ce9 100644 --- a/custom_components/hubspace/sensor.py +++ b/custom_components/hubspace/sensor.py @@ -2,6 +2,7 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import const as sensor_const from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,11 +24,13 @@ def __init__( coordinator: HubSpaceDataUpdateCoordinator, description: SensorEntityDescription, device: HubSpaceDevice, + is_numeric: bool, ) -> None: super().__init__(coordinator, context=device.id) self.coordinator = coordinator self.entity_description = description self._device = device + self._is_numeric: bool = is_numeric self._sensor_value = None @callback @@ -47,6 +50,8 @@ def update_states(self) -> None: ) for state in states: if state.functionClass == self.entity_description.key: + if self._is_numeric and isinstance(state.value, str): + state.value = int("".join(i for i in state.value if i.isdigit())) self._sensor_value = state.value @property @@ -96,6 +101,9 @@ async def async_setup_entry( dev.id, sensor.key, ) - ha_entity = HubSpaceSensor(coordinator_hubspace, sensor, dev) + is_numeric = ( + sensor.device_class not in sensor_const.NON_NUMERIC_DEVICE_CLASSES + ) + ha_entity = HubSpaceSensor(coordinator_hubspace, sensor, dev, is_numeric) entities.append(ha_entity) async_add_entities(entities) diff --git a/custom_components/hubspace/switch.py b/custom_components/hubspace/switch.py index 890d672..70201a7 100644 --- a/custom_components/hubspace/switch.py +++ b/custom_components/hubspace/switch.py @@ -35,7 +35,6 @@ def __init__( hs: HubSpaceDataUpdateCoordinator, friendly_name: str, instance: Optional[str], - device_class: str, child_id: Optional[str] = None, model: Optional[str] = None, device_id: Optional[str] = None, @@ -53,7 +52,6 @@ def __init__( } self._availability: Optional[bool] = None # Entity-specific - self._device_class = device_class self._instance = instance @callback @@ -75,11 +73,11 @@ def update_states(self) -> None: for state in states: if state.functionClass == "available": self._availability = state.value - elif state.functionClass != "power": + elif state.functionClass != self.primary_class: continue - if not self._instance: - self._state = state.value - elif state.functionInstance == self._instance: + elif self._instance and state.functionInstance != self._instance: + continue + else: self._state = state.value @property @@ -131,12 +129,16 @@ def device_info(self) -> DeviceInfo: model=model, ) + @property + def primary_class(self) -> str: + return "toggle" if self._instance else "power" + async def async_turn_on(self, **kwargs) -> None: _LOGGER.debug("Enabling %s on %s", self._instance, self._child_id) self._state = "on" states_to_set = [ HubSpaceState( - functionClass="toggle" if self._instance else "power", + functionClass=self.primary_class, functionInstance=self._instance, value=self._state, ) @@ -172,7 +174,6 @@ async def setup_entry_toggled( coordinator_hubspace, entity.friendly_name, instance, - entity.device_class, child_id=entity.id, model=entity.model, device_id=entity.device_id, @@ -190,7 +191,6 @@ async def setup_basic_switch( coordinator_hubspace, entity.friendly_name, None, - entity.device_class, child_id=entity.id, model=entity.model, device_id=entity.device_id, @@ -203,7 +203,7 @@ async def async_setup_entry( entry: HubSpaceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Add Fan entities from a config_entry.""" + """Add Switch entities from a config_entry.""" coordinator_hubspace: HubSpaceDataUpdateCoordinator = ( entry.runtime_data.coordinator_hubspace ) @@ -231,5 +231,4 @@ async def async_setup_entry( model=entity.model, manufacturer=entity.manufacturerName, ) - entities.extend(new_devs) async_add_entities(entities) diff --git a/tests/device_dumps/transformer.json b/tests/device_dumps/transformer.json new file mode 100644 index 0000000..dc3a436 --- /dev/null +++ b/tests/device_dumps/transformer.json @@ -0,0 +1,538 @@ +[ + { + "id": "f9aa07e9-a4ce-46b4-b6bc-ad3bc070bc90", + "device_id": "1a6ac487-63bd-42a3-927d-66866eb641ac", + "model": "HB-200-1215WIFIB", + "device_class": "landscape-transformer", + "default_name": "Transformer", + "default_image": "landscape-transformer-icon", + "friendly_name": "friendly-device-6", + "functions": [ + { + "id": "dc89e5bd-bfa3-4d4e-a3e9-03c35dc57005", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "power", + "type": "category", + "schedulable": true, + "values": [ + { + "id": "ac3ff6a7-d538-41eb-b916-bcae43527ce7", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "on", + "deviceValues": [ + { + "id": "ac85c5fe-6518-445f-81ff-fff289236d8c", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "1", + "value": "1" + } + ], + "range": {} + }, + { + "id": "e2159ad2-7bbb-48a7-ab64-4826970c85f5", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "off", + "deviceValues": [ + { + "id": "76944820-393c-427f-87d0-3bce162c8c56", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "1", + "value": "0" + } + ], + "range": {} + } + ] + }, + { + "id": "30606e94-b141-4f5e-b4df-6e84f84e03ba", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "timer", + "functionInstance": "zone-2", + "type": "numeric", + "schedulable": false, + "values": [ + { + "id": "ea37c700-5f51-43c1-99d1-9b9f4e6ebe72", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "timer-2", + "deviceValues": [ + { + "id": "6ca82305-53e9-41f2-9ea8-e60c3389a9c8", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "51" + } + ], + "range": { + "min": 0, + "max": 1440, + "step": 1 + } + } + ] + }, + { + "id": "8447607a-8f04-4510-9d11-0f8309dde1df", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "toggle", + "functionInstance": "zone-2", + "type": "category", + "schedulable": true, + "values": [ + { + "id": "3628cc54-75db-47db-b465-c9a3d83fb359", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "on", + "deviceValues": [ + { + "id": "91284e09-b4ae-4c24-ba2c-d9611e2c414a", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "3", + "value": "1" + } + ], + "range": {} + }, + { + "id": "7caa96fe-3fe4-4c10-b73f-80ea8101da8f", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "off", + "deviceValues": [ + { + "id": "37f1d3e6-4bba-4bab-9bf5-72e7b4fda489", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "3", + "value": "0" + } + ], + "range": {} + } + ] + }, + { + "id": "f2ccffe4-de27-49e1-b4e9-09d88de0a209", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "toggle", + "functionInstance": "zone-3", + "type": "category", + "schedulable": true, + "values": [ + { + "id": "b1ef7733-891a-431e-be83-780997d39947", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "on", + "deviceValues": [ + { + "id": "698f7dd8-69a0-4e3a-9ab6-105ecfc8d74e", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "4", + "value": "1" + } + ], + "range": {} + }, + { + "id": "28e13229-9abf-486c-a20e-a25c8cc5c30b", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "off", + "deviceValues": [ + { + "id": "3691b66d-15c0-4faa-953f-a5d1ee008938", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "4", + "value": "0" + } + ], + "range": {} + } + ] + }, + { + "id": "6855b421-4c01-4466-87d5-22c872af938f", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "output-voltage-switch", + "type": "category", + "schedulable": false, + "values": [ + { + "id": "2384e9a0-28fd-417d-b952-7986e2396469", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "15V", + "deviceValues": [ + { + "id": "2396370b-568d-4b3d-97a6-b6fe2a84caa3", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "100", + "value": "15" + } + ], + "range": {} + }, + { + "id": "83cd3637-d295-43a0-a5e6-f3b599c34aff", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "12V", + "deviceValues": [ + { + "id": "537183b7-f652-46e1-89e8-5a4ae06aa850", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "100", + "value": "12" + } + ], + "range": {} + } + ] + }, + { + "id": "de8864a6-1ec7-4125-9cc5-f36f07b7a486", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "overload-state", + "type": "category", + "schedulable": false, + "values": [ + { + "id": "0085bc83-2baf-41a1-9305-0e73ab6f6fd5", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "normal", + "deviceValues": [ + { + "id": "5728f0b9-454a-4403-8ee2-a5a96022fabd", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "400", + "value": "0" + } + ], + "range": {} + }, + { + "id": "bc8b1e32-7d5b-4333-bcd8-40f57d923465", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "overloaded", + "deviceValues": [ + { + "id": "0b2713dd-cb70-4cd2-bd07-c94dc72365ca", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "400", + "value": "1" + } + ], + "range": {} + } + ] + }, + { + "id": "ef331e0b-f3ab-41f0-8819-d8ab40e08527", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "timer", + "functionInstance": "zone-3", + "type": "numeric", + "schedulable": false, + "values": [ + { + "id": "4e6bcca7-6cff-441c-a2f6-f6cf98c8dc9b", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "timer-2", + "deviceValues": [ + { + "id": "8874897f-372e-4e5d-a888-a2d4975730b3", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "52" + } + ], + "range": { + "min": 0, + "max": 1440, + "step": 1 + } + } + ] + }, + { + "id": "f795994d-867f-40bb-bbfa-ea74a55c027a", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "toggle", + "functionInstance": "zone-1", + "type": "category", + "schedulable": true, + "values": [ + { + "id": "304fdb4c-7729-458f-aefa-b79702336051", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "on", + "deviceValues": [ + { + "id": "f4cc3c0a-922e-467d-87d9-90b3913a9077", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "2", + "value": "1" + } + ], + "range": {} + }, + { + "id": "f2e8e172-7afb-4649-a034-8a3f604f8d2e", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "off", + "deviceValues": [ + { + "id": "6fbdff3e-1b57-47c7-8ca9-1db547b626af", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "2", + "value": "0" + } + ], + "range": {} + } + ] + }, + { + "id": "280907ed-5569-4670-a574-714e2298082b", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "timer", + "functionInstance": "zone-1", + "type": "numeric", + "schedulable": false, + "values": [ + { + "id": "af4d41cc-f9d5-4077-8246-c1b59dc524db", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "timer-1", + "deviceValues": [ + { + "id": "98470038-7922-48a8-bd1c-de6b737a9bc4", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "50" + } + ], + "range": { + "min": 0, + "max": 1440, + "step": 1 + } + } + ] + }, + { + "id": "7ba5baf3-5ae9-4b49-8ed0-aeae74997ae1", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "functionClass": "watts", + "type": "numeric", + "schedulable": false, + "values": [ + { + "id": "df8510ca-01ee-4ccc-8129-853f9d24ab90", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "name": "watts", + "deviceValues": [ + { + "id": "1816420d-1f3a-4efb-ae2a-6c9c8cf1447f", + "createdTimestampMs": 0, + "updatedTimestampMs": 0, + "type": "attribute", + "key": "300" + } + ], + "range": { + "min": 0, + "max": 200, + "step": 1 + } + } + ] + } + ], + "states": [ + { + "functionClass": "power", + "value": "off", + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "timer", + "value": 0, + "lastUpdateTime": 0, + "functionInstance": "zone-2" + }, + { + "functionClass": "toggle", + "value": "on", + "lastUpdateTime": 0, + "functionInstance": "zone-2" + }, + { + "functionClass": "toggle", + "value": "off", + "lastUpdateTime": 0, + "functionInstance": "zone-3" + }, + { + "functionClass": "output-voltage-switch", + "value": "12V", + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "overload-state", + "value": "normal", + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "timer", + "value": 0, + "lastUpdateTime": 0, + "functionInstance": "zone-3" + }, + { + "functionClass": "toggle", + "value": "off", + "lastUpdateTime": 0, + "functionInstance": "zone-1" + }, + { + "functionClass": "timer", + "value": 0, + "lastUpdateTime": 0, + "functionInstance": "zone-1" + }, + { + "functionClass": "watts", + "value": 0, + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "wifi-ssid", + "value": "032fb03b-d5bc-40de-a532-61b3a92fe06d", + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "wifi-rssi", + "value": -51, + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "wifi-steady-state", + "value": "connected", + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "wifi-setup-state", + "value": "connected", + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "wifi-mac-address", + "value": "e92c9189-9329-4d1f-bdca-2755b961dc09", + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "geo-coordinates", + "value": { + "geo-coordinates": { + "latitude": "0", + "longitude": "0" + } + }, + "lastUpdateTime": 0, + "functionInstance": "system-device-location" + }, + { + "functionClass": "scheduler-flags", + "value": 1, + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "available", + "value": true, + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "visible", + "value": true, + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "direct", + "value": true, + "lastUpdateTime": 0, + "functionInstance": null + }, + { + "functionClass": "ble-mac-address", + "value": "01e508b2-63f5-40b0-b6b6-04de31b3bf6a", + "lastUpdateTime": 0, + "functionInstance": null + } + ], + "children": [], + "manufacturerName": "Hampton Bay" + } +] diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 27c2ead..80ce22a 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,18 +1,31 @@ +import pytest + from custom_components.hubspace import const, sensor from .utils import create_devices_from_data door_lock = create_devices_from_data("door-lock-TBD.json") +transformer = create_devices_from_data("transformer.json") -def test_sensor(mocked_coordinator): +@pytest.mark.parametrize( + "sensor_descr,device,is_numeric,expected", + [ + (const.SENSORS_GENERAL["battery-level"], door_lock[0], True, 80), + (const.SENSORS_GENERAL["output-voltage-switch"], transformer[0], True, 12), + (const.SENSORS_GENERAL["watts"], transformer[0], True, 0), + (const.SENSORS_GENERAL["wifi-rssi"], transformer[0], True, -51), + ], +) +def test_sensor(sensor_descr, device, is_numeric, expected, mocked_coordinator): empty_sensor = sensor.HubSpaceSensor( mocked_coordinator, - const.SENSORS_GENERAL["battery-level"], - door_lock[0], + sensor_descr, + device, + is_numeric, ) - empty_sensor.coordinator.data[const.ENTITY_SENSOR][door_lock[0].id] = { - "device": door_lock[0] - } + empty_sensor.coordinator.data[const.ENTITY_SENSOR][device.id] = {"device": device} empty_sensor.update_states() - assert empty_sensor.native_value == 80 + # Ensure the state can be correctly calculated + empty_sensor.state + assert empty_sensor.native_value == expected diff --git a/tests/test_switch.py b/tests/test_switch.py new file mode 100644 index 0000000..b331633 --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,67 @@ +import pytest + +from custom_components.hubspace import switch +from custom_components.hubspace.const import ENTITY_SWITCH + +from .utils import create_devices_from_data + + +@pytest.fixture +def single_switch(mocked_coordinator): + yield switch.HubSpaceSwitch(mocked_coordinator, "test switch", None) + + +transformer = create_devices_from_data("transformer.json")[0] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "instance,states,expected_attrs", + [ + ("zone-1", transformer.states, {"_state": "off", "_availability": True}), + ("zone-2", transformer.states, {"_state": "on", "_availability": True}), + ], +) +async def test_update_states(instance, states, expected_attrs, single_switch): + single_switch._instance = instance + single_switch.states = states + single_switch.coordinator.data[ENTITY_SWITCH][ + single_switch._child_id + ] = single_switch + single_switch.update_states() + for key, val in expected_attrs.items(): + assert getattr(single_switch, key) == val + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "dev,expected_instances", + [ + (transformer, ["zone-2", "zone-3", "zone-1"]), + ], +) +async def test_setup_entry_toggled(dev, expected_instances, mocked_coordinator): + res = await switch.setup_entry_toggled(mocked_coordinator, dev) + assert len(res) == len(expected_instances) + for ind, entity in enumerate(res): + assert entity._instance == expected_instances[ind] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "devs,expected_instances", + [ + ({transformer.id: transformer}, ["zone-2", "zone-3", "zone-1"]), + ], +) +async def test_async_setup_entry(devs, expected_instances, mocked_coordinator, mocker): + mocker.patch.object(switch, "dr") + hass = mocker.Mock() + add_entities = mocker.Mock() + entry = mocker.Mock() + entry.runtime_data.coordinator_hubspace = mocked_coordinator + mocked_coordinator.data[ENTITY_SWITCH] = devs + await switch.async_setup_entry(hass, entry, add_entities) + added_devs = add_entities.call_args_list[0][0] + for ind, dev in enumerate(added_devs): + assert dev[0]._instance == expected_instances[ind]