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:
parent
259befa65f
commit
deed5f327c
5 changed files with 105 additions and 38 deletions
|
@ -1,7 +1,8 @@
|
||||||
"""Support for deCONZ binary sensors."""
|
"""Support for deCONZ binary sensors."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import ValuesView
|
from collections.abc import Callable, ValuesView
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from pydeconz.sensor import (
|
from pydeconz.sensor import (
|
||||||
Alarm,
|
Alarm,
|
||||||
|
@ -48,6 +49,25 @@ ATTR_ORIENTATION = "orientation"
|
||||||
ATTR_TILTANGLE = "tiltangle"
|
ATTR_TILTANGLE = "tiltangle"
|
||||||
ATTR_VIBRATIONSTRENGTH = "vibrationstrength"
|
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 = {
|
ENTITY_DESCRIPTIONS = {
|
||||||
Alarm: BinarySensorEntityDescription(
|
Alarm: BinarySensorEntityDescription(
|
||||||
key="alarm",
|
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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
|
@ -95,7 +137,7 @@ async def async_setup_entry(
|
||||||
| ValuesView[PydeconzSensor] = gateway.api.sensors.values(),
|
| ValuesView[PydeconzSensor] = gateway.api.sensors.values(),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add binary sensor from deCONZ."""
|
"""Add binary sensor from deCONZ."""
|
||||||
entities: list[DeconzBinarySensor | DeconzTampering] = []
|
entities: list[DeconzBinarySensor | DeconzPropertyBinarySensor] = []
|
||||||
|
|
||||||
for sensor in sensors:
|
for sensor in sensors:
|
||||||
|
|
||||||
|
@ -108,11 +150,20 @@ async def async_setup_entry(
|
||||||
):
|
):
|
||||||
entities.append(DeconzBinarySensor(sensor, gateway))
|
entities.append(DeconzBinarySensor(sensor, gateway))
|
||||||
|
|
||||||
if sensor.tampered is not None:
|
known_sensor_entities = set(gateway.entities[DOMAIN])
|
||||||
known_tampering_sensors = set(gateway.entities[DOMAIN])
|
for sensor_description in BINARY_SENSOR_DESCRIPTIONS:
|
||||||
new_tampering_sensor = DeconzTampering(sensor, gateway)
|
|
||||||
if new_tampering_sensor.unique_id not in known_tampering_sensors:
|
if (
|
||||||
entities.append(new_tampering_sensor)
|
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:
|
if entities:
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
@ -179,34 +230,38 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity):
|
||||||
return attr
|
return attr
|
||||||
|
|
||||||
|
|
||||||
class DeconzTampering(DeconzDevice, BinarySensorEntity):
|
class DeconzPropertyBinarySensor(DeconzDevice, BinarySensorEntity):
|
||||||
"""Representation of a deCONZ tampering sensor."""
|
"""Representation of a deCONZ Property sensor."""
|
||||||
|
|
||||||
TYPE = DOMAIN
|
TYPE = DOMAIN
|
||||||
_device: PydeconzSensor
|
_device: PydeconzSensor
|
||||||
|
entity_description: DeconzBinarySensorDescription
|
||||||
|
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
def __init__(
|
||||||
_attr_device_class = BinarySensorDeviceClass.TAMPER
|
self,
|
||||||
|
device: PydeconzSensor,
|
||||||
def __init__(self, device: PydeconzSensor, gateway: DeconzGateway) -> None:
|
gateway: DeconzGateway,
|
||||||
|
description: DeconzBinarySensorDescription,
|
||||||
|
) -> None:
|
||||||
"""Initialize deCONZ binary sensor."""
|
"""Initialize deCONZ binary sensor."""
|
||||||
|
self.entity_description = description
|
||||||
super().__init__(device, gateway)
|
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
|
@property
|
||||||
def unique_id(self) -> str:
|
def unique_id(self) -> str:
|
||||||
"""Return a unique identifier for this device."""
|
"""Return a unique identifier for this device."""
|
||||||
return f"{self.serial}-tampered"
|
return f"{self.serial}-{self.entity_description.suffix.lower()}"
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_update_callback(self) -> None:
|
def async_update_callback(self) -> None:
|
||||||
"""Update the sensor's state."""
|
"""Update the sensor's state."""
|
||||||
keys = {"tampered", "reachable"}
|
if self._device.changed_keys.intersection(self._update_keys):
|
||||||
if self._device.changed_keys.intersection(keys):
|
|
||||||
super().async_update_callback()
|
super().async_update_callback()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool | None:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
return self._device.tampered # type: ignore[no-any-return]
|
return self.entity_description.value_fn(self._device)
|
||||||
|
|
|
@ -78,6 +78,7 @@ class DeconzSensorDescriptionMixin:
|
||||||
|
|
||||||
suffix: str
|
suffix: str
|
||||||
update_key: str
|
update_key: str
|
||||||
|
required_attr: str
|
||||||
value_fn: Callable[[PydeconzSensor], float | int | None]
|
value_fn: Callable[[PydeconzSensor], float | int | None]
|
||||||
|
|
||||||
|
|
||||||
|
@ -142,6 +143,7 @@ ENTITY_DESCRIPTIONS = {
|
||||||
SENSOR_DESCRIPTIONS = [
|
SENSOR_DESCRIPTIONS = [
|
||||||
DeconzSensorDescription(
|
DeconzSensorDescription(
|
||||||
key="temperature",
|
key="temperature",
|
||||||
|
required_attr="secondary_temperature",
|
||||||
value_fn=lambda device: device.secondary_temperature,
|
value_fn=lambda device: device.secondary_temperature,
|
||||||
suffix="Temperature",
|
suffix="Temperature",
|
||||||
update_key="temperature",
|
update_key="temperature",
|
||||||
|
@ -151,6 +153,7 @@ SENSOR_DESCRIPTIONS = [
|
||||||
),
|
),
|
||||||
DeconzSensorDescription(
|
DeconzSensorDescription(
|
||||||
key="air_quality_ppb",
|
key="air_quality_ppb",
|
||||||
|
required_attr="air_quality_ppb",
|
||||||
value_fn=lambda device: device.air_quality_ppb,
|
value_fn=lambda device: device.air_quality_ppb,
|
||||||
suffix="PPB",
|
suffix="PPB",
|
||||||
update_key="airqualityppb",
|
update_key="airqualityppb",
|
||||||
|
@ -207,19 +210,18 @@ async def async_setup_entry(
|
||||||
):
|
):
|
||||||
entities.append(DeconzSensor(sensor, gateway))
|
entities.append(DeconzSensor(sensor, gateway))
|
||||||
|
|
||||||
|
known_sensor_entities = set(gateway.entities[DOMAIN])
|
||||||
for sensor_description in SENSOR_DESCRIPTIONS:
|
for sensor_description in SENSOR_DESCRIPTIONS:
|
||||||
|
|
||||||
try:
|
if not hasattr(
|
||||||
if sensor_description.value_fn(sensor):
|
sensor, sensor_description.required_attr
|
||||||
known_sensors = set(gateway.entities[DOMAIN])
|
) or not sensor_description.value_fn(sensor):
|
||||||
new_sensor = DeconzPropertySensor(
|
|
||||||
sensor, gateway, sensor_description
|
|
||||||
)
|
|
||||||
if new_sensor.unique_id not in known_sensors:
|
|
||||||
entities.append(new_sensor)
|
|
||||||
except AttributeError:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
new_sensor = DeconzPropertySensor(sensor, gateway, sensor_description)
|
||||||
|
if new_sensor.unique_id not in known_sensor_entities:
|
||||||
|
entities.append(new_sensor)
|
||||||
|
|
||||||
if entities:
|
if entities:
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
|
@ -112,7 +112,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket):
|
||||||
with patch.dict(DECONZ_WEB_REQUEST, data):
|
with patch.dict(DECONZ_WEB_REQUEST, data):
|
||||||
config_entry = await setup_deconz_integration(hass, aioclient_mock)
|
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
|
assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_PENDING
|
||||||
|
|
||||||
# Event signals alarm control panel armed away
|
# 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)
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
|
||||||
states = hass.states.async_all()
|
states = hass.states.async_all()
|
||||||
assert len(states) == 3
|
assert len(states) == 4
|
||||||
for state in states:
|
for state in states:
|
||||||
assert state.state == STATE_UNAVAILABLE
|
assert state.state == STATE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
|
@ -126,7 +126,12 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket):
|
||||||
"1": {
|
"1": {
|
||||||
"name": "Presence sensor",
|
"name": "Presence sensor",
|
||||||
"type": "ZHAPresence",
|
"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},
|
"config": {"on": True, "reachable": True, "temperature": 10},
|
||||||
"uniqueid": "00:00:00:00:00:00:00:00-00",
|
"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)
|
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")
|
presence_tamper = hass.states.get("binary_sensor.presence_sensor_tampered")
|
||||||
assert presence_tamper.state == STATE_OFF
|
assert presence_tamper.state == STATE_OFF
|
||||||
assert (
|
assert (
|
||||||
presence_tamper.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.TAMPER
|
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 = {
|
event_changed_sensor = {
|
||||||
"t": "event",
|
"t": "event",
|
||||||
|
@ -155,10 +169,6 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket):
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert hass.states.get("binary_sensor.presence_sensor_tampered").state == STATE_ON
|
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)
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
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
|
# 1 alarm control device + 2 additional devices for deconz service and host
|
||||||
assert (
|
assert (
|
||||||
len(async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 3
|
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)
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
|
||||||
states = hass.states.async_all()
|
states = hass.states.async_all()
|
||||||
assert len(hass.states.async_all()) == 3
|
assert len(hass.states.async_all()) == 4
|
||||||
for state in states:
|
for state in states:
|
||||||
assert state.state == STATE_UNAVAILABLE
|
assert state.state == STATE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue