Add moisture sensors entities for gardena (#98282)
Add support for soil moisture sensors for gardena
This commit is contained in:
parent
364d872a47
commit
816f834807
10 changed files with 244 additions and 14 deletions
|
@ -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
|
||||
]
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue