Add low battery binary sensor to deCONZ integration (#64168)

* Make tamper sensor a property sensor

* Add low battery binary sensor as a binary property sensor

* Change according to review comment

* Use value_fn lambda

* Fix comparison

* Specific entity_description typing

* Minimize the code block affected by catching AttributeError

* Update homeassistant/components/deconz/binary_sensor.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/deconz/binary_sensor.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Avoid try statement

* Reflect review changes in sensor platform as well

* Store known sensor entities once per device

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Robert Svensson 2022-01-17 22:35:20 +01:00 committed by GitHub
parent 259befa65f
commit deed5f327c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 105 additions and 38 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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