From 816f834807341aa0036dbed140f4a734de34f6bc Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 23 Aug 2023 22:46:34 +0200 Subject: [PATCH] Add moisture sensors entities for gardena (#98282) Add support for soil moisture sensors for gardena --- .../gardena_bluetooth/binary_sensor.py | 16 +++++- .../components/gardena_bluetooth/button.py | 7 ++- .../gardena_bluetooth/coordinator.py | 15 ++++-- .../components/gardena_bluetooth/number.py | 34 ++++++++++-- .../components/gardena_bluetooth/sensor.py | 53 ++++++++++++++++++- .../components/gardena_bluetooth/strings.json | 15 ++++++ .../snapshots/test_number.ambr | 34 ++++++++++++ .../snapshots/test_sensor.ambr | 30 +++++++++++ .../gardena_bluetooth/test_number.py | 27 +++++++++- .../gardena_bluetooth/test_sensor.py | 27 +++++++++- 10 files changed, 244 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index 0285f7bdf82..b66cb8cd00d 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -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 ] diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index a9dac9902f8..1ed738a9690 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -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 ] diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index 67ed056f7b1..73552e25c03 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -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 diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index f53a7720577..f0ba5dbd2fe 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -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: diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index dd2bde43cc4..396d8469ffc 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -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() diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 538f97ffdb3..01eac80d1e0 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -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" } diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index 0c464f7cbc1..0b39525dc82 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -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': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_title_sensor_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_connected_state.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sensor threshold', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_title_sensor_threshold', + 'last_changed': , + 'last_updated': , + 'state': '45.0', + }) +# --- # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 8df37b40abc..1c33e8ebab9 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -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': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_sensor_battery', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_connected_state.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Title Sensor battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_sensor_battery', + 'last_changed': , + 'last_updated': , + 'state': '45', + }) +# --- # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-sensor.mock_title_valve_closing] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/gardena_bluetooth/test_number.py b/tests/components/gardena_bluetooth/test_number.py index 0003532fb60..ce2d19b8c63 100644 --- a/tests/components/gardena_bluetooth/test_number.py +++ b/tests/components/gardena_bluetooth/test_number.py @@ -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 diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py index 307a9467f00..dc0d0cb4809 100644 --- a/tests/components/gardena_bluetooth/test_sensor.py +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -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