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 gardena_bluetooth.const import Valve
from gardena_bluetooth.const import Sensor, Valve
from gardena_bluetooth.parse import CharacteristicBool
from homeassistant.components.binary_sensor import (
@ -26,6 +26,11 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool(""))
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
return {self.char.uuid}
DESCRIPTIONS = (
GardenaBluetoothBinarySensorEntityDescription(
@ -35,6 +40,13 @@ DESCRIPTIONS = (
entity_category=EntityCategory.DIAGNOSTIC,
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."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
GardenaBluetoothBinarySensor(coordinator, description)
GardenaBluetoothBinarySensor(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
]

View File

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

View File

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

View File

@ -3,8 +3,9 @@ from __future__ import annotations
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 (
Characteristic,
CharacteristicInt,
CharacteristicLong,
CharacteristicUInt16,
@ -16,7 +17,7 @@ from homeassistant.components.number import (
NumberMode,
)
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.helpers.entity_platform import AddEntitiesCallback
@ -35,6 +36,15 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
char: CharacteristicInt | CharacteristicUInt16 | CharacteristicLong = field(
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 = (
@ -81,6 +91,18 @@ DESCRIPTIONS = (
entity_category=EntityCategory.CONFIG,
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."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[NumberEntity] = [
GardenaBluetoothNumber(coordinator, description)
GardenaBluetoothNumber(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
]
@ -110,6 +132,12 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
self._attr_native_value = None
else:
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()
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 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 homeassistant.components.sensor import (
@ -32,6 +32,15 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
"""Description of entity."""
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 = (
@ -51,6 +60,40 @@ DESCRIPTIONS = (
native_unit_of_measurement=PERCENTAGE,
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."""
coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[GardenaBluetoothEntity] = [
GardenaBluetoothSensor(coordinator, description)
GardenaBluetoothSensor(coordinator, description, description.context)
for description in DESCRIPTIONS
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)
)
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()

View File

@ -23,6 +23,9 @@
"binary_sensor": {
"valve_connected_state": {
"name": "Valve connection"
},
"sensor_connected_state": {
"name": "Sensor connection"
}
},
"button": {
@ -45,12 +48,24 @@
},
"seasonal_adjust": {
"name": "Seasonal adjust"
},
"sensor_threshold": {
"name": "Sensor threshold"
}
},
"sensor": {
"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": {
"name": "Valve closing"
}

View File

@ -67,6 +67,40 @@
'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]
StateSnapshot({
'attributes': ReadOnlyDict({

View File

@ -1,4 +1,34 @@
# 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]
StateSnapshot({
'attributes': ReadOnlyDict({

View File

@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import Mock, call
from gardena_bluetooth.const import Valve
from gardena_bluetooth.const import Sensor, Valve
from gardena_bluetooth.exceptions import (
CharacteristicNoAccess,
GardenaBluetoothException,
@ -149,3 +149,28 @@ async def test_bluetooth_error_unavailable(
await scan_step()
assert hass.states.get("number.mock_title_remaining_open_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."""
from collections.abc import Awaitable, Callable
from gardena_bluetooth.const import Battery, Valve
from gardena_bluetooth.const import Battery, Sensor, Valve
import pytest
from syrupy.assertion import SnapshotAssertion
@ -52,3 +52,28 @@ async def test_setup(
mock_read_char_raw[uuid] = char_raw
await scan_step()
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