Add moisture sensors entities for gardena (#98282)

Add support for soil moisture sensors for gardena
This commit is contained in:
Joakim Plate 2023-08-23 22:46:34 +02:00 committed by GitHub
parent 364d872a47
commit 816f834807
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 244 additions and 14 deletions

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from gardena_bluetooth.const import Valve from gardena_bluetooth.const import Sensor, Valve
from gardena_bluetooth.parse import CharacteristicBool from gardena_bluetooth.parse import CharacteristicBool
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
@ -26,6 +26,11 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool(""))
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
return {self.char.uuid}
DESCRIPTIONS = ( DESCRIPTIONS = (
GardenaBluetoothBinarySensorEntityDescription( GardenaBluetoothBinarySensorEntityDescription(
@ -35,6 +40,13 @@ DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
char=Valve.connected_state, char=Valve.connected_state,
), ),
GardenaBluetoothBinarySensorEntityDescription(
key=Sensor.connected_state.uuid,
translation_key="sensor_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.connected_state,
),
) )
@ -44,7 +56,7 @@ async def async_setup_entry(
"""Set up binary sensor based on a config entry.""" """Set up binary sensor based on a config entry."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities = [ entities = [
GardenaBluetoothBinarySensor(coordinator, description) GardenaBluetoothBinarySensor(coordinator, description, description.context)
for description in DESCRIPTIONS for description in DESCRIPTIONS
if description.key in coordinator.characteristics if description.key in coordinator.characteristics
] ]

View File

@ -22,6 +22,11 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool(""))
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
return {self.char.uuid}
DESCRIPTIONS = ( DESCRIPTIONS = (
GardenaBluetoothButtonEntityDescription( GardenaBluetoothButtonEntityDescription(
@ -40,7 +45,7 @@ async def async_setup_entry(
"""Set up button based on a config entry.""" """Set up button based on a config entry."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities = [ entities = [
GardenaBluetoothButton(coordinator, description) GardenaBluetoothButton(coordinator, description, description.context)
for description in DESCRIPTIONS for description in DESCRIPTIONS
if description.key in coordinator.characteristics if description.key in coordinator.characteristics
] ]

View File

@ -117,18 +117,25 @@ class GardenaBluetoothEntity(CoordinatorEntity[Coordinator]):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return super().available and bluetooth.async_address_present( return (
self.coordinator.last_update_success
and bluetooth.async_address_present(
self.hass, self.coordinator.address, True self.hass, self.coordinator.address, True
) )
and self._attr_available
)
class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity): class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity):
"""Coordinator entity for entities with entity description.""" """Coordinator entity for entities with entity description."""
def __init__( def __init__(
self, coordinator: Coordinator, description: EntityDescription self,
coordinator: Coordinator,
description: EntityDescription,
context: set[str],
) -> None: ) -> None:
"""Initialize description entity.""" """Initialize description entity."""
super().__init__(coordinator, {description.key}) super().__init__(coordinator, context)
self._attr_unique_id = f"{coordinator.address}-{description.key}" self._attr_unique_id = f"{coordinator.address}-{description.key}"
self.entity_description = description self.entity_description = description

View File

@ -3,8 +3,9 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from gardena_bluetooth.const import DeviceConfiguration, Valve from gardena_bluetooth.const import DeviceConfiguration, Sensor, Valve
from gardena_bluetooth.parse import ( from gardena_bluetooth.parse import (
Characteristic,
CharacteristicInt, CharacteristicInt,
CharacteristicLong, CharacteristicLong,
CharacteristicUInt16, CharacteristicUInt16,
@ -16,7 +17,7 @@ from homeassistant.components.number import (
NumberMode, NumberMode,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -35,6 +36,15 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
char: CharacteristicInt | CharacteristicUInt16 | CharacteristicLong = field( char: CharacteristicInt | CharacteristicUInt16 | CharacteristicLong = field(
default_factory=lambda: CharacteristicInt("") default_factory=lambda: CharacteristicInt("")
) )
connected_state: Characteristic | None = None
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
data = {self.char.uuid}
if self.connected_state:
data.add(self.connected_state.uuid)
return data
DESCRIPTIONS = ( DESCRIPTIONS = (
@ -81,6 +91,18 @@ DESCRIPTIONS = (
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
char=DeviceConfiguration.seasonal_adjust, char=DeviceConfiguration.seasonal_adjust,
), ),
GardenaBluetoothNumberEntityDescription(
key=Sensor.threshold.uuid,
translation_key="sensor_threshold",
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.BOX,
native_min_value=0.0,
native_max_value=100.0,
native_step=1.0,
entity_category=EntityCategory.CONFIG,
char=Sensor.threshold,
connected_state=Sensor.connected_state,
),
) )
@ -90,7 +112,7 @@ async def async_setup_entry(
"""Set up entity based on a config entry.""" """Set up entity based on a config entry."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[NumberEntity] = [ entities: list[NumberEntity] = [
GardenaBluetoothNumber(coordinator, description) GardenaBluetoothNumber(coordinator, description, description.context)
for description in DESCRIPTIONS for description in DESCRIPTIONS
if description.key in coordinator.characteristics if description.key in coordinator.characteristics
] ]
@ -110,6 +132,12 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
self._attr_native_value = None self._attr_native_value = None
else: else:
self._attr_native_value = float(data) self._attr_native_value = float(data)
if char := self.entity_description.connected_state:
self._attr_available = bool(self.coordinator.get_cached(char))
else:
self._attr_available = True
super()._handle_coordinator_update() super()._handle_coordinator_update()
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from gardena_bluetooth.const import Battery, Valve from gardena_bluetooth.const import Battery, Sensor, Valve
from gardena_bluetooth.parse import Characteristic from gardena_bluetooth.parse import Characteristic
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -32,6 +32,15 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
"""Description of entity.""" """Description of entity."""
char: Characteristic = field(default_factory=lambda: Characteristic("")) char: Characteristic = field(default_factory=lambda: Characteristic(""))
connected_state: Characteristic | None = None
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
data = {self.char.uuid}
if self.connected_state:
data.add(self.connected_state.uuid)
return data
DESCRIPTIONS = ( DESCRIPTIONS = (
@ -51,6 +60,40 @@ DESCRIPTIONS = (
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
char=Battery.battery_level, char=Battery.battery_level,
), ),
GardenaBluetoothSensorEntityDescription(
key=Sensor.battery_level.uuid,
translation_key="sensor_battery_level",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
char=Sensor.battery_level,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.value.uuid,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.MOISTURE,
native_unit_of_measurement=PERCENTAGE,
char=Sensor.value,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.type.uuid,
translation_key="sensor_type",
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.type,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.measurement_timestamp.uuid,
translation_key="sensor_measurement_timestamp",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.measurement_timestamp,
connected_state=Sensor.connected_state,
),
) )
@ -60,7 +103,7 @@ async def async_setup_entry(
"""Set up Gardena Bluetooth sensor based on a config entry.""" """Set up Gardena Bluetooth sensor based on a config entry."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[GardenaBluetoothEntity] = [ entities: list[GardenaBluetoothEntity] = [
GardenaBluetoothSensor(coordinator, description) GardenaBluetoothSensor(coordinator, description, description.context)
for description in DESCRIPTIONS for description in DESCRIPTIONS
if description.key in coordinator.characteristics if description.key in coordinator.characteristics
] ]
@ -81,6 +124,12 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity):
tzinfo=dt_util.get_time_zone(self.hass.config.time_zone) tzinfo=dt_util.get_time_zone(self.hass.config.time_zone)
) )
self._attr_native_value = value self._attr_native_value = value
if char := self.entity_description.connected_state:
self._attr_available = bool(self.coordinator.get_cached(char))
else:
self._attr_available = True
super()._handle_coordinator_update() super()._handle_coordinator_update()

View File

@ -23,6 +23,9 @@
"binary_sensor": { "binary_sensor": {
"valve_connected_state": { "valve_connected_state": {
"name": "Valve connection" "name": "Valve connection"
},
"sensor_connected_state": {
"name": "Sensor connection"
} }
}, },
"button": { "button": {
@ -45,12 +48,24 @@
}, },
"seasonal_adjust": { "seasonal_adjust": {
"name": "Seasonal adjust" "name": "Seasonal adjust"
},
"sensor_threshold": {
"name": "Sensor threshold"
} }
}, },
"sensor": { "sensor": {
"activation_reason": { "activation_reason": {
"name": "Activation reason" "name": "Activation reason"
}, },
"sensor_battery_level": {
"name": "Sensor battery"
},
"sensor_type": {
"name": "Sensor type"
},
"sensor_measurement_timestamp": {
"name": "Sensor timestamp"
},
"remaining_open_timestamp": { "remaining_open_timestamp": {
"name": "Valve closing" "name": "Valve closing"
} }

View File

@ -67,6 +67,40 @@
'state': 'unavailable', 'state': 'unavailable',
}) })
# --- # ---
# name: test_connected_state
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Sensor threshold',
'max': 100.0,
'min': 0.0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.mock_title_sensor_threshold',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_connected_state.1
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Sensor threshold',
'max': 100.0,
'min': 0.0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.mock_title_sensor_threshold',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '45.0',
})
# ---
# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({

View File

@ -1,4 +1,34 @@
# serializer version: 1 # serializer version: 1
# name: test_connected_state
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Mock Title Sensor battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_sensor_battery',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_connected_state.1
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Mock Title Sensor battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_sensor_battery',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '45',
})
# ---
# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-sensor.mock_title_valve_closing] # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-sensor.mock_title_valve_closing]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({

View File

@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from unittest.mock import Mock, call from unittest.mock import Mock, call
from gardena_bluetooth.const import Valve from gardena_bluetooth.const import Sensor, Valve
from gardena_bluetooth.exceptions import ( from gardena_bluetooth.exceptions import (
CharacteristicNoAccess, CharacteristicNoAccess,
GardenaBluetoothException, GardenaBluetoothException,
@ -149,3 +149,28 @@ async def test_bluetooth_error_unavailable(
await scan_step() await scan_step()
assert hass.states.get("number.mock_title_remaining_open_time") == snapshot assert hass.states.get("number.mock_title_remaining_open_time") == snapshot
assert hass.states.get("number.mock_title_manual_watering_time") == snapshot assert hass.states.get("number.mock_title_manual_watering_time") == snapshot
async def test_connected_state(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_entry: MockConfigEntry,
mock_read_char_raw: dict[str, bytes],
scan_step: Callable[[], Awaitable[None]],
) -> None:
"""Verify that a connectivity error makes all entities unavailable."""
mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode(
False
)
mock_read_char_raw[Sensor.threshold.uuid] = Sensor.threshold.encode(45)
await setup_entry(hass, mock_entry, [Platform.NUMBER])
assert hass.states.get("number.mock_title_sensor_threshold") == snapshot
mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode(
True
)
await scan_step()
assert hass.states.get("number.mock_title_sensor_threshold") == snapshot

View File

@ -1,7 +1,7 @@
"""Test Gardena Bluetooth sensor.""" """Test Gardena Bluetooth sensor."""
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from gardena_bluetooth.const import Battery, Valve from gardena_bluetooth.const import Battery, Sensor, Valve
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -52,3 +52,28 @@ async def test_setup(
mock_read_char_raw[uuid] = char_raw mock_read_char_raw[uuid] = char_raw
await scan_step() await scan_step()
assert hass.states.get(entity_id) == snapshot assert hass.states.get(entity_id) == snapshot
async def test_connected_state(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_entry: MockConfigEntry,
mock_read_char_raw: dict[str, bytes],
scan_step: Callable[[], Awaitable[None]],
) -> None:
"""Verify that a connectivity error makes all entities unavailable."""
mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode(
False
)
mock_read_char_raw[Sensor.battery_level.uuid] = Sensor.battery_level.encode(45)
await setup_entry(hass, mock_entry, [Platform.SENSOR])
assert hass.states.get("sensor.mock_title_sensor_battery") == snapshot
mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode(
True
)
await scan_step()
assert hass.states.get("sensor.mock_title_sensor_battery") == snapshot