diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index fa8ed2e315d..1e6edbbe415 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,7 +1,8 @@ """Support for deCONZ binary sensors.""" from __future__ import annotations -from collections.abc import ValuesView +from collections.abc import Callable, ValuesView +from dataclasses import dataclass from pydeconz.sensor import ( Alarm, @@ -48,6 +49,25 @@ ATTR_ORIENTATION = "orientation" ATTR_TILTANGLE = "tiltangle" ATTR_VIBRATIONSTRENGTH = "vibrationstrength" + +@dataclass +class DeconzBinarySensorDescriptionMixin: + """Required values when describing secondary sensor attributes.""" + + suffix: str + update_key: str + required_attr: str + value_fn: Callable[[PydeconzSensor], bool | None] + + +@dataclass +class DeconzBinarySensorDescription( + BinarySensorEntityDescription, + DeconzBinarySensorDescriptionMixin, +): + """Class describing deCONZ binary sensor entities.""" + + ENTITY_DESCRIPTIONS = { Alarm: BinarySensorEntityDescription( key="alarm", @@ -80,6 +100,28 @@ ENTITY_DESCRIPTIONS = { } +BINARY_SENSOR_DESCRIPTIONS = [ + DeconzBinarySensorDescription( + key="tamper", + required_attr="tampered", + value_fn=lambda device: device.tampered, + suffix="Tampered", + update_key="tampered", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DeconzBinarySensorDescription( + key="low_battery", + required_attr="low_battery", + value_fn=lambda device: device.low_battery, + suffix="Low Battery", + update_key="lowbattery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -95,7 +137,7 @@ async def async_setup_entry( | ValuesView[PydeconzSensor] = gateway.api.sensors.values(), ) -> None: """Add binary sensor from deCONZ.""" - entities: list[DeconzBinarySensor | DeconzTampering] = [] + entities: list[DeconzBinarySensor | DeconzPropertyBinarySensor] = [] for sensor in sensors: @@ -108,11 +150,20 @@ async def async_setup_entry( ): entities.append(DeconzBinarySensor(sensor, gateway)) - if sensor.tampered is not None: - known_tampering_sensors = set(gateway.entities[DOMAIN]) - new_tampering_sensor = DeconzTampering(sensor, gateway) - if new_tampering_sensor.unique_id not in known_tampering_sensors: - entities.append(new_tampering_sensor) + known_sensor_entities = set(gateway.entities[DOMAIN]) + for sensor_description in BINARY_SENSOR_DESCRIPTIONS: + + if ( + not hasattr(sensor, sensor_description.required_attr) + or sensor_description.value_fn(sensor) is None + ): + continue + + new_sensor = DeconzPropertyBinarySensor( + sensor, gateway, sensor_description + ) + if new_sensor.unique_id not in known_sensor_entities: + entities.append(new_sensor) if entities: async_add_entities(entities) @@ -179,34 +230,38 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): return attr -class DeconzTampering(DeconzDevice, BinarySensorEntity): - """Representation of a deCONZ tampering sensor.""" +class DeconzPropertyBinarySensor(DeconzDevice, BinarySensorEntity): + """Representation of a deCONZ Property sensor.""" TYPE = DOMAIN _device: PydeconzSensor + entity_description: DeconzBinarySensorDescription - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_device_class = BinarySensorDeviceClass.TAMPER - - def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None: + def __init__( + self, + device: PydeconzSensor, + gateway: DeconzGateway, + description: DeconzBinarySensorDescription, + ) -> None: """Initialize deCONZ binary sensor.""" + self.entity_description = description super().__init__(device, gateway) - self._attr_name = f"{self._device.name} Tampered" + self._attr_name = f"{self._device.name} {description.suffix}" + self._update_keys = {description.update_key, "reachable"} @property def unique_id(self) -> str: """Return a unique identifier for this device.""" - return f"{self.serial}-tampered" + return f"{self.serial}-{self.entity_description.suffix.lower()}" @callback def async_update_callback(self) -> None: """Update the sensor's state.""" - keys = {"tampered", "reachable"} - if self._device.changed_keys.intersection(keys): + if self._device.changed_keys.intersection(self._update_keys): super().async_update_callback() @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the state of the sensor.""" - return self._device.tampered # type: ignore[no-any-return] + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 0177c15e8ae..5c870ffd937 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -78,6 +78,7 @@ class DeconzSensorDescriptionMixin: suffix: str update_key: str + required_attr: str value_fn: Callable[[PydeconzSensor], float | int | None] @@ -142,6 +143,7 @@ ENTITY_DESCRIPTIONS = { SENSOR_DESCRIPTIONS = [ DeconzSensorDescription( key="temperature", + required_attr="secondary_temperature", value_fn=lambda device: device.secondary_temperature, suffix="Temperature", update_key="temperature", @@ -151,6 +153,7 @@ SENSOR_DESCRIPTIONS = [ ), DeconzSensorDescription( key="air_quality_ppb", + required_attr="air_quality_ppb", value_fn=lambda device: device.air_quality_ppb, suffix="PPB", update_key="airqualityppb", @@ -207,19 +210,18 @@ async def async_setup_entry( ): entities.append(DeconzSensor(sensor, gateway)) + known_sensor_entities = set(gateway.entities[DOMAIN]) for sensor_description in SENSOR_DESCRIPTIONS: - try: - if sensor_description.value_fn(sensor): - known_sensors = set(gateway.entities[DOMAIN]) - new_sensor = DeconzPropertySensor( - sensor, gateway, sensor_description - ) - if new_sensor.unique_id not in known_sensors: - entities.append(new_sensor) - except AttributeError: + if not hasattr( + sensor, sensor_description.required_attr + ) or not sensor_description.value_fn(sensor): continue + new_sensor = DeconzPropertySensor(sensor, gateway, sensor_description) + if new_sensor.unique_id not in known_sensor_entities: + entities.append(new_sensor) + if entities: async_add_entities(entities) diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index 5bafdc2fbb6..7e83e027607 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -112,7 +112,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_PENDING # Event signals alarm control panel armed away @@ -298,7 +298,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): await hass.config_entries.async_unload(config_entry.entry_id) states = hass.states.async_all() - assert len(states) == 3 + assert len(states) == 4 for state in states: assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 32c7f1c3eb9..04c5335ffd5 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -126,7 +126,12 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket): "1": { "name": "Presence sensor", "type": "ZHAPresence", - "state": {"dark": False, "presence": False, "tampered": False}, + "state": { + "dark": False, + "lowbattery": False, + "presence": False, + "tampered": False, + }, "config": {"on": True, "reachable": True, "temperature": 10}, "uniqueid": "00:00:00:00:00:00:00:00-00", }, @@ -137,12 +142,21 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket): ent_reg = er.async_get(hass) - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 + hass.states.get("binary_sensor.presence_sensor_low_battery").state == STATE_OFF + assert ( + ent_reg.async_get("binary_sensor.presence_sensor_low_battery").entity_category + is EntityCategory.DIAGNOSTIC + ) presence_tamper = hass.states.get("binary_sensor.presence_sensor_tampered") assert presence_tamper.state == STATE_OFF assert ( presence_tamper.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.TAMPER ) + assert ( + ent_reg.async_get("binary_sensor.presence_sensor_tampered").entity_category + is EntityCategory.DIAGNOSTIC + ) event_changed_sensor = { "t": "event", @@ -155,10 +169,6 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket): await hass.async_block_till_done() assert hass.states.get("binary_sensor.presence_sensor_tampered").state == STATE_ON - assert ( - ent_reg.async_get("binary_sensor.presence_sensor_tampered").entity_category - == EntityCategory.DIAGNOSTIC - ) await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index ca76631728e..76babab36be 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -269,7 +269,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): device_registry = await hass.helpers.device_registry.async_get_registry() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 # 1 alarm control device + 2 additional devices for deconz service and host assert ( len(async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 3 @@ -404,7 +404,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): await hass.config_entries.async_unload(config_entry.entry_id) states = hass.states.async_all() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 for state in states: assert state.state == STATE_UNAVAILABLE