From dd0b23afb06b939315587fdde63739d0b622bdc2 Mon Sep 17 00:00:00 2001 From: Alistair Francis Date: Wed, 30 Jul 2025 23:07:47 +1000 Subject: [PATCH] husqvarna_automower_ble: Support battery percentage sensor (#146159) Signed-off-by: Alistair Francis --- .../husqvarna_automower_ble/__init__.py | 1 + .../husqvarna_automower_ble/coordinator.py | 6 +-- .../husqvarna_automower_ble/entity.py | 16 ++++++ .../husqvarna_automower_ble/sensor.py | 51 ++++++++++++++++++ .../snapshots/test_sensor.ambr | 54 +++++++++++++++++++ .../husqvarna_automower_ble/test_sensor.py | 32 +++++++++++ 6 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/husqvarna_automower_ble/sensor.py create mode 100644 tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr create mode 100644 tests/components/husqvarna_automower_ble/test_sensor.py diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index f168e84be4c..fd4521549a2 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -19,6 +19,7 @@ type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator] PLATFORMS = [ Platform.LAWN_MOWER, + Platform.SENSOR, ] diff --git a/homeassistant/components/husqvarna_automower_ble/coordinator.py b/homeassistant/components/husqvarna_automower_ble/coordinator.py index c7781becd76..ef9ccfa5a47 100644 --- a/homeassistant/components/husqvarna_automower_ble/coordinator.py +++ b/homeassistant/components/husqvarna_automower_ble/coordinator.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: SCAN_INTERVAL = timedelta(seconds=60) -class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): +class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, str | int]]): """Class to manage fetching data.""" def __init__( @@ -67,11 +67,11 @@ class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): except BleakError as err: raise UpdateFailed("Failed to connect") from err - async def _async_update_data(self) -> dict[str, bytes]: + async def _async_update_data(self) -> dict[str, str | int]: """Poll the device.""" LOGGER.debug("Polling device") - data: dict[str, bytes] = {} + data: dict[str, str | int] = {} try: if not self.mower.is_connected(): diff --git a/homeassistant/components/husqvarna_automower_ble/entity.py b/homeassistant/components/husqvarna_automower_ble/entity.py index d2873d933ff..cb62f36027a 100644 --- a/homeassistant/components/husqvarna_automower_ble/entity.py +++ b/homeassistant/components/husqvarna_automower_ble/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -28,3 +29,18 @@ class HusqvarnaAutomowerBleEntity(CoordinatorEntity[HusqvarnaCoordinator]): def available(self) -> bool: """Return if entity is available.""" return super().available and self.coordinator.mower.is_connected() + + +class HusqvarnaAutomowerBleDescriptorEntity(HusqvarnaAutomowerBleEntity): + """Coordinator entity for entities with entity description.""" + + def __init__( + self, coordinator: HusqvarnaCoordinator, description: EntityDescription + ) -> None: + """Initialize description entity.""" + super().__init__(coordinator) + + self._attr_unique_id = ( + f"{coordinator.address}_{coordinator.channel_id}_{description.key}" + ) + self.entity_description = description diff --git a/homeassistant/components/husqvarna_automower_ble/sensor.py b/homeassistant/components/husqvarna_automower_ble/sensor.py new file mode 100644 index 00000000000..f747133c950 --- /dev/null +++ b/homeassistant/components/husqvarna_automower_ble/sensor.py @@ -0,0 +1,51 @@ +"""Support for sensor entities.""" + +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HusqvarnaConfigEntry +from .entity import HusqvarnaAutomowerBleDescriptorEntity + +DESCRIPTIONS = ( + SensorEntityDescription( + key="battery_level", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HusqvarnaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Husqvarna Automower Ble sensor based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + HusqvarnaAutomowerBleSensor(coordinator, description) + for description in DESCRIPTIONS + if description.key in coordinator.data + ) + + +class HusqvarnaAutomowerBleSensor(HusqvarnaAutomowerBleDescriptorEntity, SensorEntity): + """Representation of a sensor.""" + + entity_description: SensorEntityDescription + + @property + def native_value(self) -> str | int: + """Return the previously fetched value.""" + return self.coordinator.data[self.entity_description.key] diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8f2bfadf56a --- /dev/null +++ b/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_setup[sensor.husqvarna_automower_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.husqvarna_automower_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'husqvarna_automower_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000003_1197489078_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[sensor.husqvarna_automower_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Husqvarna AutoMower Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.husqvarna_automower_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/husqvarna_automower_ble/test_sensor.py b/tests/components/husqvarna_automower_ble/test_sensor.py new file mode 100644 index 00000000000..d1f0a13cc43 --- /dev/null +++ b/tests/components/husqvarna_automower_ble/test_sensor.py @@ -0,0 +1,32 @@ +"""Test the Husqvarna Automower Bluetooth setup.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.usefixtures("mock_automower_client") + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup creates expected entities.""" + + with patch( + "homeassistant.components.husqvarna_automower_ble.PLATFORMS", [Platform.SENSOR] + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)