From 8d589c326ff8e6a7a52a8c12b016b043221f37bf Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 10 Aug 2026 16:11:52 +0200 Subject: [PATCH] Adjust gardena to key on unique id (#178624) --- .../gardena_bluetooth/binary_sensor.py | 2 +- .../components/gardena_bluetooth/button.py | 2 +- .../gardena_bluetooth/coordinator.py | 39 +++++----- .../components/gardena_bluetooth/number.py | 9 ++- .../components/gardena_bluetooth/select.py | 2 +- .../components/gardena_bluetooth/sensor.py | 9 ++- .../components/gardena_bluetooth/switch.py | 6 +- .../components/gardena_bluetooth/text.py | 2 +- .../components/gardena_bluetooth/valve.py | 2 +- .../components/gardena_bluetooth/conftest.py | 31 +++++--- .../snapshots/test_number.ambr | 12 +-- .../gardena_bluetooth/test_binary_sensor.py | 10 +-- .../gardena_bluetooth/test_button.py | 4 +- .../components/gardena_bluetooth/test_init.py | 24 +++--- .../gardena_bluetooth/test_number.py | 63 +++++++++++----- .../gardena_bluetooth/test_select.py | 16 ++-- .../gardena_bluetooth/test_sensor.py | 74 +++++++++++++------ .../gardena_bluetooth/test_switch.py | 36 ++++++++- .../components/gardena_bluetooth/test_text.py | 24 +++--- .../gardena_bluetooth/test_valve.py | 8 +- 20 files changed, 238 insertions(+), 137 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index b705673bc9a6..151daf24869e 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -28,7 +28,7 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio @property def context(self) -> set[str]: """Context needed for update coordinator.""" - return {self.char.uuid} + return {self.char.unique_id} DESCRIPTIONS = ( diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 2f672eea3e3f..375f8230f57b 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -24,7 +24,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription): @property def context(self) -> set[str]: """Context needed for update coordinator.""" - return {self.char.uuid} + return {self.char.unique_id} DESCRIPTIONS = ( diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index 3670d392eb9e..629dcc47e4db 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from typing import override +from typing import Any, override from gardena_bluetooth.client import Client from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation @@ -38,7 +38,7 @@ class DeviceUnavailable(HomeAssistantError): """Raised if device can't be found.""" -class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): +class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data.""" config_entry: GardenaBluetoothConfigEntry @@ -62,7 +62,7 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): self.address = address self.data = {} self.client = client - self.characteristics: set[str] = set() + self.characteristics: dict[str, Characteristic] = {} self.device_info = DeviceInfo( identifiers={(DOMAIN, address)}, connections={(dr.CONNECTION_BLUETOOTH, address)}, @@ -98,7 +98,7 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): await self._update_timestamp(DeviceConfiguration.unix_timestamp) await self._update_timestamp(AquaContour.unix_timestamp) - self.characteristics = set(chars.keys()) + self.characteristics = chars self.device_info = DeviceInfo( { **self.device_info, @@ -122,23 +122,30 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): LOGGER.debug("No access to update internal time") @override - async def _async_update_data(self) -> dict[str, bytes]: + async def _async_update_data(self) -> dict[str, Any]: """Poll the device.""" - uuids: set[str] = { - uuid for context in self.async_contexts() for uuid in context + # A context may name a characteristic this device lacks, so filter up front. + unique_ids: set[str] = { + unique_id + for context in self.async_contexts() + for unique_id in context + if unique_id in self.characteristics } - if not uuids: + if not unique_ids: return {} - data: dict[str, bytes] = {} - for uuid in uuids: + data: dict[str, Any] = {} + for unique_id in unique_ids: + char = self.characteristics[unique_id] try: - data[uuid] = await self.client.read_char_raw(uuid) + data[unique_id] = await self.client.read_char(char) except CharacteristicNoAccess as exception: - LOGGER.debug("Unable to get data for %s due to %s", uuid, exception) + LOGGER.debug( + "Unable to get data for %s due to %s", unique_id, exception + ) except (GardenaBluetoothException, DeviceUnavailable) as exception: raise UpdateFailed( - f"Unable to update data for {uuid} due to {exception}" + f"Unable to update data for {unique_id} due to {exception}" ) from exception return data @@ -146,9 +153,7 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): self, char: Characteristic[CharacteristicType] ) -> CharacteristicType | None: """Read cached characteristic.""" - if data := self.data.get(char.uuid): - return char.decode(data) - return None + return self.data.get(char.unique_id) async def write( self, char: Characteristic[CharacteristicType], value: CharacteristicType @@ -161,5 +166,5 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): f"Unable to write characteristic {char} dur to {exception}" ) from exception - self.data[char.uuid] = char.encode(value) + self.data[char.unique_id] = value await self.async_refresh() diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 5caea06b499a..3beecd1f8269 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -44,9 +44,9 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): @property def context(self) -> set[str]: """Context needed for update coordinator.""" - data = {self.char.uuid} + data = {self.char.unique_id} if self.connected_state: - data.add(self.connected_state.uuid) + data.add(self.connected_state.unique_id) return data @@ -178,7 +178,8 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity): else: self._attr_native_value = float(data) / self.entity_description.scale - if char := self.entity_description.connected_state: + char = self.entity_description.connected_state + if char and char.unique_id in self.coordinator.characteristics: self._attr_available = bool(self.coordinator.get_cached(char)) else: self._attr_available = True @@ -210,7 +211,7 @@ class GardenaBluetoothRemainingOpenSetNumber(GardenaBluetoothEntity, NumberEntit coordinator: GardenaBluetoothCoordinator, ) -> None: """Initialize the remaining time entity.""" - super().__init__(coordinator, {Valve.remaining_open_time.uuid}) + super().__init__(coordinator, {Valve.remaining_open_time.unique_id}) self._attr_unique_id = f"{coordinator.address}-remaining_open_set" @override diff --git a/homeassistant/components/gardena_bluetooth/select.py b/homeassistant/components/gardena_bluetooth/select.py index f0de5dc2869d..e7f0a0588a78 100644 --- a/homeassistant/components/gardena_bluetooth/select.py +++ b/homeassistant/components/gardena_bluetooth/select.py @@ -48,7 +48,7 @@ class GardenaBluetoothSelectEntityDescription(SelectEntityDescription): @property def context(self) -> set[str]: """Context needed for update coordinator.""" - return {self.char.uuid} + return {self.char.unique_id} DESCRIPTIONS = ( diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 5a7c06586ed8..6916e4b69147 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -67,9 +67,9 @@ class GardenaBluetoothSensorEntityDescription[T](SensorEntityDescription): @property def context(self) -> set[str]: """Context needed for update coordinator.""" - data = {self.char.uuid} + data = {self.char.unique_id} if self.connected_state: - data.add(self.connected_state.uuid) + data.add(self.connected_state.unique_id) return data @@ -273,7 +273,8 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity): value = self.entity_description.get(value) self._attr_native_value = value - if char := self.entity_description.connected_state: + char = self.entity_description.connected_state + if char and char.unique_id in self.coordinator.characteristics: self._attr_available = bool(self.coordinator.get_cached(char)) else: self._attr_available = True @@ -294,7 +295,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity): key: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, {char.uuid}) + super().__init__(coordinator, {char.unique_id}) self._attr_unique_id = f"{coordinator.address}-{key}" self._attr_translation_key = key self._char = char diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index 2445394c4c1c..ccbb0de25706 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -44,7 +44,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity): ) -> None: """Initialize the switch.""" super().__init__( - coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid} + coordinator, {Valve.state.unique_id, Valve.manual_watering_time.unique_id} ) self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}" self._attr_translation_key = "state" @@ -59,10 +59,10 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity): @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - if not (data := self.coordinator.data.get(Valve.manual_watering_time.uuid)): + value = self.coordinator.get_cached(Valve.manual_watering_time) + if value is None: raise HomeAssistantError("Unable to get manual activation time.") - value = Valve.manual_watering_time.decode(data) await self.coordinator.write(Valve.remaining_open_time, value) self._attr_is_on = True self.async_write_ha_state() diff --git a/homeassistant/components/gardena_bluetooth/text.py b/homeassistant/components/gardena_bluetooth/text.py index a48405ab720f..1f84a3873bf1 100644 --- a/homeassistant/components/gardena_bluetooth/text.py +++ b/homeassistant/components/gardena_bluetooth/text.py @@ -24,7 +24,7 @@ class GardenaBluetoothTextEntityDescription(TextEntityDescription): @property def context(self) -> set[str]: """Context needed for update coordinator.""" - return {self.char.uuid} + return {self.char.unique_id} DESCRIPTIONS = ( diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index 49de3d28332b..f71ac6d06d5b 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -53,7 +53,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): ) -> None: """Initialize the switch.""" super().__init__( - coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid} + coordinator, {Valve.state.unique_id, Valve.manual_watering_time.unique_id} ) self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}" diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index bfee095eab0f..dd1e9a1afe60 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -75,8 +75,8 @@ def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: def mock_read_char_raw(): """Mock data on device.""" return { - DeviceInformation.firmware_version.uuid: b"1.2.3", - DeviceInformation.model_number.uuid: b"Mock Model", + DeviceInformation.firmware_version.unique_id: b"1.2.3", + DeviceInformation.model_number.unique_id: b"Mock Model", } @@ -122,13 +122,24 @@ def mock_client( SENTINEL = object() + def _chars() -> list[Characteristic]: + product_type = client_class.call_args.args[1] + return [ + char + for service in Service.services_for_product_type(product_type) + for char in service.characteristics.values() + ] + def _read_char(char: Characteristic, default: Any = SENTINEL): try: - return char.decode(mock_read_char_raw[char.uuid]) + val = mock_read_char_raw[char.unique_id] except KeyError: if default is SENTINEL: raise CharacteristicNotFound from KeyError return default + if isinstance(val, Exception): + raise val + return char.decode(val) def _read_char_raw(uuid: str, default: Any = SENTINEL): try: @@ -142,17 +153,13 @@ def mock_client( return val def _all_char_uuid(): - return set(mock_read_char_raw.keys()) + """Physical uuids the device exposes.""" + return {char.uuid for char in _chars() if char.unique_id in mock_read_char_raw} def _all_char(): - product_type = client_class.call_args.args[1] - services = Service.services_for_product_type(product_type) - return { - char.unique_id: char - for service in services - for char in service.characteristics.values() - if char.uuid in mock_read_char_raw - } + """Every characteristic on an exposed uuid, virtual ones included.""" + uuids = _all_char_uuid() + return {char.unique_id: char for char in _chars() if char.uuid in uuids} client = Mock(spec_set=Client) client.read_char.side_effect = _read_char diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index f9007d40f7a6..2335d50af2e8 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -244,7 +244,7 @@ 'state': 'unknown', }) # --- -# name: test_setup[service_info3-98bd0d13-0b0e-421a-84e5-ddbf75dc6de4-raw3-number.mock_title_manual_watering_time] +# name: test_setup[service_info3-98bd0d13-0b0e-421a-84e5-ddbf75dc6de4:1-raw3-number.mock_title_manual_watering_time] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'duration', @@ -263,7 +263,7 @@ 'state': '100.0', }) # --- -# name: test_setup[service_info3-98bd0d13-0b0e-421a-84e5-ddbf75dc6de4-raw3-number.mock_title_manual_watering_time].1 +# name: test_setup[service_info3-98bd0d13-0b0e-421a-84e5-ddbf75dc6de4:1-raw3-number.mock_title_manual_watering_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ : 'duration', @@ -282,7 +282,7 @@ 'state': '10.0', }) # --- -# name: test_setup[service_info4-98bd0112-0b0e-421a-84e5-ddbf75dc6de4-raw4-number.mock_title_sector] +# name: test_setup[service_info4-98bd0112-0b0e-421a-84e5-ddbf75dc6de4:1-raw4-number.mock_title_sector] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Mock Title Sector', @@ -300,7 +300,7 @@ 'state': '359.0', }) # --- -# name: test_setup[service_info4-98bd0112-0b0e-421a-84e5-ddbf75dc6de4-raw4-number.mock_title_sector].1 +# name: test_setup[service_info4-98bd0112-0b0e-421a-84e5-ddbf75dc6de4:1-raw4-number.mock_title_sector].1 StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Mock Title Sector', @@ -318,7 +318,7 @@ 'state': '10.0', }) # --- -# name: test_setup[service_info5-98bd0111-0b0e-421a-84e5-ddbf75dc6de4-raw5-number.mock_title_distance] +# name: test_setup[service_info5-98bd0111-0b0e-421a-84e5-ddbf75dc6de4:1-raw5-number.mock_title_distance] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Mock Title Distance', @@ -336,7 +336,7 @@ 'state': '100.0', }) # --- -# name: test_setup[service_info5-98bd0111-0b0e-421a-84e5-ddbf75dc6de4-raw5-number.mock_title_distance].1 +# name: test_setup[service_info5-98bd0111-0b0e-421a-84e5-ddbf75dc6de4:1-raw5-number.mock_title_distance].1 StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Mock Title Distance', diff --git a/tests/components/gardena_bluetooth/test_binary_sensor.py b/tests/components/gardena_bluetooth/test_binary_sensor.py index 97ba69ba2390..c57448d40dd3 100644 --- a/tests/components/gardena_bluetooth/test_binary_sensor.py +++ b/tests/components/gardena_bluetooth/test_binary_sensor.py @@ -15,10 +15,10 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("uuid", "raw", "entity_id"), + ("unique_id", "raw", "entity_id"), [ ( - Valve.connected_state.uuid, + Valve.connected_state.unique_id, [b"\x01", b"\x00"], "binary_sensor.mock_title_valve_connection", ), @@ -30,17 +30,17 @@ async def test_setup( mock_entry: MockConfigEntry, mock_read_char_raw: dict[str, bytes], scan_step: Callable[[], Awaitable[None]], - uuid: str, + unique_id: str, raw: list[bytes], entity_id: str, ) -> None: """Test setup creates expected entities.""" - mock_read_char_raw[uuid] = raw[0] + mock_read_char_raw[unique_id] = raw[0] await setup_entry(hass, mock_entry, [Platform.BINARY_SENSOR]) assert hass.states.get(entity_id) == snapshot for char_raw in raw[1:]: - mock_read_char_raw[uuid] = char_raw + mock_read_char_raw[unique_id] = char_raw await scan_step() assert hass.states.get(entity_id) == snapshot diff --git a/tests/components/gardena_bluetooth/test_button.py b/tests/components/gardena_bluetooth/test_button.py index 685afd8c337f..c2a5dc32f549 100644 --- a/tests/components/gardena_bluetooth/test_button.py +++ b/tests/components/gardena_bluetooth/test_button.py @@ -19,7 +19,7 @@ from tests.common import MockConfigEntry @pytest.fixture def mock_switch_chars(mock_read_char_raw): """Mock data on device.""" - mock_read_char_raw[Reset.factory_reset.uuid] = b"\x00" + mock_read_char_raw[Reset.factory_reset.unique_id] = b"\x00" return mock_read_char_raw @@ -36,7 +36,7 @@ async def test_setup( await setup_entry(hass, mock_entry, [Platform.BUTTON]) assert hass.states.get(entity_id) == snapshot - mock_switch_chars[Reset.factory_reset.uuid] = b"\x01" + mock_switch_chars[Reset.factory_reset.unique_id] = b"\x01" await scan_step() assert hass.states.get(entity_id) == snapshot diff --git a/tests/components/gardena_bluetooth/test_init.py b/tests/components/gardena_bluetooth/test_init.py index 9dfbc9303f57..15528b7c9cc8 100644 --- a/tests/components/gardena_bluetooth/test_init.py +++ b/tests/components/gardena_bluetooth/test_init.py @@ -40,14 +40,14 @@ from tests.components.bluetooth import inject_bluetooth_service_info pytest.param( WATER_TIMER_SERVICE_INFO, { - Battery.battery_level.uuid: Battery.battery_level.encode(100), - DeviceInformation.model_number.uuid: ( + Battery.battery_level.unique_id: Battery.battery_level.encode(100), + DeviceInformation.model_number.unique_id: ( DeviceInformation.model_number.encode("Model Number TBD") ), - DeviceInformation.firmware_version.uuid: ( + DeviceInformation.firmware_version.unique_id: ( DeviceInformation.firmware_version.encode("1.2.3") ), - DeviceConfiguration.custom_device_name.uuid: ( + DeviceConfiguration.custom_device_name.unique_id: ( DeviceConfiguration.custom_device_name.encode("My timer") ), }, @@ -56,16 +56,16 @@ from tests.components.bluetooth import inject_bluetooth_service_info pytest.param( AQUA_CONTOUR_SERVICE_INFO, { - AquaContourBattery.battery_level.uuid: ( + AquaContourBattery.battery_level.unique_id: ( AquaContourBattery.battery_level.encode(100) ), - DeviceInformation.model_number.uuid: ( + DeviceInformation.model_number.unique_id: ( DeviceInformation.model_number.encode("Aqua Contour") ), - DeviceInformation.firmware_version.uuid: ( + DeviceInformation.firmware_version.unique_id: ( DeviceInformation.firmware_version.encode("2.0.0") ), - AquaContour.custom_device_name.uuid: ( + AquaContour.custom_device_name.unique_id: ( AquaContour.custom_device_name.encode("My contour") ), }, @@ -103,7 +103,9 @@ async def test_migrate_config_entry_product_type( ) -> None: """Test migration: product type resolved immediately from existing advertisement.""" - mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100) + mock_read_char_raw[Battery.battery_level.unique_id] = Battery.battery_level.encode( + 100 + ) inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) @@ -127,7 +129,9 @@ async def test_migrate_config_entry_product_type_delayed( ) -> None: """Test migration: product type discovered via active scan after a delay.""" - mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100) + mock_read_char_raw[Battery.battery_level.unique_id] = Battery.battery_level.encode( + 100 + ) legacy_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/gardena_bluetooth/test_number.py b/tests/components/gardena_bluetooth/test_number.py index fae6ecf63f1b..47fdc289f011 100644 --- a/tests/components/gardena_bluetooth/test_number.py +++ b/tests/components/gardena_bluetooth/test_number.py @@ -30,11 +30,11 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") @pytest.mark.parametrize( - ("service_info", "uuid", "raw", "entity_id"), + ("service_info", "unique_id", "raw", "entity_id"), [ ( WATER_TIMER_SERVICE_INFO, - Valve.manual_watering_time.uuid, + Valve.manual_watering_time.unique_id, [ Valve.manual_watering_time.encode(100), Valve.manual_watering_time.encode(10), @@ -43,7 +43,7 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") ), ( WATER_TIMER_SERVICE_INFO, - Valve.remaining_open_time.uuid, + Valve.remaining_open_time.unique_id, [ Valve.remaining_open_time.encode(100), Valve.remaining_open_time.encode(10), @@ -54,13 +54,13 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") ), ( WATER_TIMER_SERVICE_INFO, - Valve.remaining_open_time.uuid, + Valve.remaining_open_time.unique_id, [Valve.remaining_open_time.encode(100)], "number.mock_title_open_for", ), ( AQUA_CONTOUR_SERVICE_INFO, - AquaContourWatering.manual_watering_time.uuid, + AquaContourWatering.manual_watering_time.unique_id, [ AquaContourWatering.manual_watering_time.encode(100), AquaContourWatering.manual_watering_time.encode(10), @@ -69,7 +69,7 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") ), ( AQUA_CONTOUR_SERVICE_INFO, - Spray.sector.uuid, + Spray.sector.unique_id, [ Spray.sector.encode(359), Spray.sector.encode(10), @@ -78,7 +78,7 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") ), ( AQUA_CONTOUR_SERVICE_INFO, - Spray.distance.uuid, + Spray.distance.unique_id, [ Spray.distance.encode(1000), Spray.distance.encode(10), @@ -93,18 +93,18 @@ async def test_setup( mock_read_char_raw: dict[str, bytes], scan_step: Callable[[], Awaitable[None]], service_info: BluetoothServiceInfo, - uuid: str, + unique_id: str, raw: list[bytes], entity_id: str, ) -> None: """Test setup creates expected entities.""" - mock_read_char_raw[uuid] = raw[0] + mock_read_char_raw[unique_id] = raw[0] await setup_entry(hass, platforms=[Platform.NUMBER], service_info=service_info) assert hass.states.get(entity_id) == snapshot for char_raw in raw[1:]: - mock_read_char_raw[uuid] = char_raw + mock_read_char_raw[unique_id] = char_raw await scan_step() assert hass.states.get(entity_id) == snapshot @@ -147,7 +147,7 @@ async def test_config( ) -> None: """Test setup creates expected entities.""" - mock_read_char_raw[char.uuid] = char.encode(value) + mock_read_char_raw[char.unique_id] = char.encode(value) await setup_entry(hass, platforms=[Platform.NUMBER], service_info=service_info) assert hass.states.get(entity_id) @@ -172,10 +172,10 @@ async def test_bluetooth_error_unavailable( ) -> None: """Verify that a connectivity error makes all entities unavailable.""" - mock_read_char_raw[Valve.manual_watering_time.uuid] = ( + mock_read_char_raw[Valve.manual_watering_time.unique_id] = ( Valve.manual_watering_time.encode(0) ) - mock_read_char_raw[Valve.remaining_open_time.uuid] = ( + mock_read_char_raw[Valve.remaining_open_time.unique_id] = ( Valve.remaining_open_time.encode(0) ) @@ -183,8 +183,8 @@ async def test_bluetooth_error_unavailable( assert hass.states.get("number.mock_title_remaining_open_time") == snapshot assert hass.states.get("number.mock_title_manual_watering_time") == snapshot - mock_read_char_raw[Valve.manual_watering_time.uuid] = GardenaBluetoothException( - "Test for errors on bluetooth" + mock_read_char_raw[Valve.manual_watering_time.unique_id] = ( + GardenaBluetoothException("Test for errors on bluetooth") ) await scan_step() @@ -192,6 +192,29 @@ async def test_bluetooth_error_unavailable( assert hass.states.get("number.mock_title_manual_watering_time") == snapshot +async def test_missing_connected_state( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Verify a device lacking the connected state characteristic stays usable. + + Entities are created on their primary characteristic alone, so their context + can name a connected state the device does not expose. + """ + + mock_read_char_raw[Sensor.threshold.unique_id] = Sensor.threshold.encode(45) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + await scan_step() + + # The primary characteristic still reports, so the entity stays available. + state = hass.states.get("number.mock_title_sensor_threshold") + assert state + assert state.state == "45.0" + + async def test_connected_state( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -201,16 +224,16 @@ async def test_connected_state( ) -> 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.connected_state.unique_id] = ( + Sensor.connected_state.encode(False) ) - mock_read_char_raw[Sensor.threshold.uuid] = Sensor.threshold.encode(45) + mock_read_char_raw[Sensor.threshold.unique_id] = 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 + mock_read_char_raw[Sensor.connected_state.unique_id] = ( + Sensor.connected_state.encode(True) ) await scan_step() diff --git a/tests/components/gardena_bluetooth/test_select.py b/tests/components/gardena_bluetooth/test_select.py index c8ffdd002c80..5effffcc6696 100644 --- a/tests/components/gardena_bluetooth/test_select.py +++ b/tests/components/gardena_bluetooth/test_select.py @@ -31,7 +31,7 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") @pytest.fixture def mock_chars(mock_read_char_raw): """Mock data on device.""" - mock_read_char_raw[AquaContourWatering.watering_active.uuid] = b"\x00" + mock_read_char_raw[AquaContourWatering.watering_active.unique_id] = b"\x00" return mock_read_char_raw @@ -41,11 +41,13 @@ def mock_chars(mock_read_char_raw): pytest.param( AQUA_CONTOUR_SERVICE_INFO, { - AquaContourWatering.watering_active.uuid: ( + AquaContourWatering.watering_active.unique_id: ( AquaContourWatering.watering_active.encode(0) ), - AquaContour.operation_mode.uuid: AquaContour.operation_mode.encode(0), - AquaContourPosition.active_position.uuid: ( + AquaContour.operation_mode.unique_id: AquaContour.operation_mode.encode( + 0 + ), + AquaContourPosition.active_position.unique_id: ( AquaContourPosition.active_position.encode(0) ), }, @@ -80,7 +82,7 @@ async def test_state_change( """Test setup creates expected entities.""" entity_id = "select.mock_title_watering" - mock_read_char_raw[AquaContourWatering.watering_active.uuid] = ( + mock_read_char_raw[AquaContourWatering.watering_active.unique_id] = ( AquaContourWatering.watering_active.encode(AquaContourWateringMode.REST) ) @@ -91,7 +93,7 @@ async def test_state_change( assert state assert state.state == "rest" - mock_read_char_raw[AquaContourWatering.watering_active.uuid] = ( + mock_read_char_raw[AquaContourWatering.watering_active.unique_id] = ( AquaContourWatering.watering_active.encode(AquaContourWateringMode.CONTOUR_1) ) await scan_step() @@ -109,7 +111,7 @@ async def test_select( ) -> None: """Test switching makes correct calls.""" - mock_read_char_raw[AquaContourWatering.watering_active.uuid] = b"\x00" + mock_read_char_raw[AquaContourWatering.watering_active.unique_id] = b"\x00" entity_id = "select.mock_title_watering" await setup_entry( hass, platforms=[Platform.SELECT], service_info=AQUA_CONTOUR_SERVICE_INFO diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py index cf7a49d8e6b5..6cb2f625b720 100644 --- a/tests/components/gardena_bluetooth/test_sensor.py +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -37,18 +37,18 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") @pytest.mark.parametrize( - ("service_info", "uuid", "raw", "entity_id"), + ("service_info", "unique_id", "raw", "entity_id"), [ pytest.param( WATER_TIMER_SERVICE_INFO, - Battery.battery_level.uuid, + Battery.battery_level.unique_id, [Battery.battery_level.encode(100), Battery.battery_level.encode(10)], "sensor.mock_title_battery", id="standard_sensor", ), pytest.param( WATER_TIMER_SERVICE_INFO, - Valve.remaining_open_time.uuid, + Valve.remaining_open_time.unique_id, [ Valve.remaining_open_time.encode(100), Valve.remaining_open_time.encode(10), @@ -65,17 +65,17 @@ async def test_setup( mock_read_char_raw: dict[str, bytes], scan_step: Callable[[], Awaitable[None]], service_info: BluetoothServiceInfo, - uuid: str, + unique_id: str, raw: list[bytes], entity_id: str, ) -> None: """Test setup creates expected entities.""" - mock_read_char_raw[uuid] = raw[0] + mock_read_char_raw[unique_id] = raw[0] await setup_entry(hass, platforms=[Platform.SENSOR], service_info=service_info) assert hass.states.get(entity_id) == snapshot for char_raw in raw[1:]: - mock_read_char_raw[uuid] = char_raw + mock_read_char_raw[unique_id] = char_raw await scan_step() assert hass.states.get(entity_id) == snapshot @@ -86,9 +86,11 @@ async def test_setup( pytest.param( WATER_TIMER_SERVICE_INFO, { - Battery.battery_level.uuid: Battery.battery_level.encode(100), - Valve.remaining_open_time.uuid: Valve.remaining_open_time.encode(10), - Valve.activation_reason.uuid: Valve.activation_reason.encode( + Battery.battery_level.unique_id: Battery.battery_level.encode(100), + Valve.remaining_open_time.unique_id: Valve.remaining_open_time.encode( + 10 + ), + Valve.activation_reason.unique_id: Valve.activation_reason.encode( ActivationReason.SCHEDULE ), }, @@ -97,19 +99,19 @@ async def test_setup( pytest.param( AQUA_CONTOUR_SERVICE_INFO, { - AquaContourBattery.battery_level.uuid: ( + AquaContourBattery.battery_level.unique_id: ( AquaContourBattery.battery_level.encode(100) ), - FlowStatistics.overall.uuid: FlowStatistics.overall.encode(111), - FlowStatistics.current.uuid: FlowStatistics.overall.encode(222), - Spray.current_distance.uuid: Spray.current_distance.encode(333), - Spray.current_sector.uuid: Spray.current_sector.encode(2), - EventHistory.error.uuid: EventHistory.error.encode( + FlowStatistics.overall.unique_id: FlowStatistics.overall.encode(111), + FlowStatistics.current.unique_id: FlowStatistics.overall.encode(222), + Spray.current_distance.unique_id: Spray.current_distance.encode(333), + Spray.current_sector.unique_id: Spray.current_sector.encode(2), + EventHistory.error.unique_id: EventHistory.error.encode( ErrorData( 1, 1, datetime(2000, 1, 1), AquaContourErrorCode.FLASH_ERROR ) ), - AquaContourWatering.remaining_watering_time.uuid: ( + AquaContourWatering.remaining_watering_time.unique_id: ( AquaContourWatering.remaining_watering_time.encode(100) ), }, @@ -118,8 +120,8 @@ async def test_setup( pytest.param( PRESSURE_TANK_SERVICE_INFO, { - Pump.tank_preassure.uuid: Pump.tank_preassure.encode(3312), - Pump.water_temperature.uuid: Pump.water_temperature.encode(21), + Pump.tank_preassure.unique_id: Pump.tank_preassure.encode(3312), + Pump.water_temperature.unique_id: Pump.water_temperature.encode(21), }, id="pressure_tank", ), @@ -142,6 +144,32 @@ async def test_sensors( await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) +async def test_missing_connected_state( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Verify a device lacking the connected state characteristic still polls. + + Entities are created on their primary characteristic alone, so their context + can name a connected state the device does not expose. + """ + + mock_read_char_raw[Sensor.battery_level.unique_id] = Sensor.battery_level.encode(45) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + await scan_step() + + coordinator = mock_entry.runtime_data + assert coordinator.last_update_success + + # The primary characteristic still reports, so the entity stays available. + state = hass.states.get("sensor.mock_title_sensor_battery") + assert state + assert state.state == "45" + + async def test_connected_state( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -151,16 +179,16 @@ async def test_connected_state( ) -> 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.connected_state.unique_id] = ( + Sensor.connected_state.encode(False) ) - mock_read_char_raw[Sensor.battery_level.uuid] = Sensor.battery_level.encode(45) + mock_read_char_raw[Sensor.battery_level.unique_id] = 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 + mock_read_char_raw[Sensor.connected_state.unique_id] = ( + Sensor.connected_state.encode(True) ) await scan_step() diff --git a/tests/components/gardena_bluetooth/test_switch.py b/tests/components/gardena_bluetooth/test_switch.py index e9cdbffaf35c..77cc53f8dc60 100644 --- a/tests/components/gardena_bluetooth/test_switch.py +++ b/tests/components/gardena_bluetooth/test_switch.py @@ -26,11 +26,11 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") @pytest.fixture def mock_switch_chars(mock_read_char_raw): """Mock data on device.""" - mock_read_char_raw[Valve.state.uuid] = b"\x00" - mock_read_char_raw[Valve.remaining_open_time.uuid] = ( + mock_read_char_raw[Valve.state.unique_id] = b"\x00" + mock_read_char_raw[Valve.remaining_open_time.unique_id] = ( Valve.remaining_open_time.encode(0) ) - mock_read_char_raw[Valve.manual_watering_time.uuid] = ( + mock_read_char_raw[Valve.manual_watering_time.unique_id] = ( Valve.manual_watering_time.encode(1000) ) return mock_read_char_raw @@ -50,7 +50,7 @@ async def test_setup( await setup_entry(hass, mock_entry, [Platform.SWITCH]) assert hass.states.get(entity_id) == snapshot - mock_switch_chars[Valve.state.uuid] = b"\x01" + mock_switch_chars[Valve.state.unique_id] = b"\x01" await scan_step() assert hass.states.get(entity_id) == snapshot @@ -85,3 +85,31 @@ async def test_switching( call(Valve.remaining_open_time, 1000), call(Valve.remaining_open_time, 0), ] + + +async def test_switching_zero_watering_time( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_switch_chars: dict[str, bytes], +) -> None: + """Test a manual watering time of zero is a valid duration, not a missing one.""" + + mock_switch_chars[Valve.manual_watering_time.unique_id] = ( + Valve.manual_watering_time.encode(0) + ) + + entity_id = "switch.mock_title_open" + await setup_entry(hass, mock_entry, [Platform.SWITCH]) + assert hass.states.get(entity_id) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_client.write_char.mock_calls == [ + call(Valve.remaining_open_time, 0), + ] diff --git a/tests/components/gardena_bluetooth/test_text.py b/tests/components/gardena_bluetooth/test_text.py index da0d49bd5470..2ca573e4d3da 100644 --- a/tests/components/gardena_bluetooth/test_text.py +++ b/tests/components/gardena_bluetooth/test_text.py @@ -29,16 +29,16 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") pytest.param( AQUA_CONTOUR_SERVICE_INFO, { - AquaContourPosition.position_name_1.uuid: b"Position 1\x00", - AquaContourPosition.position_name_2.uuid: b"Position 2\x00", - AquaContourPosition.position_name_3.uuid: b"Position 3\x00", - AquaContourPosition.position_name_4.uuid: b"Position 4\x00", - AquaContourPosition.position_name_5.uuid: b"Position 5\x00", - AquaContourContours.contour_name_1.uuid: b"Contour 1\x00", - AquaContourContours.contour_name_2.uuid: b"Contour 2\x00", - AquaContourContours.contour_name_3.uuid: b"Contour 3\x00", - AquaContourContours.contour_name_4.uuid: b"Contour 4\x00", - AquaContourContours.contour_name_5.uuid: b"Contour 5\x00", + AquaContourPosition.position_name_1.unique_id: b"Position 1\x00", + AquaContourPosition.position_name_2.unique_id: b"Position 2\x00", + AquaContourPosition.position_name_3.unique_id: b"Position 3\x00", + AquaContourPosition.position_name_4.unique_id: b"Position 4\x00", + AquaContourPosition.position_name_5.unique_id: b"Position 5\x00", + AquaContourContours.contour_name_1.unique_id: b"Contour 1\x00", + AquaContourContours.contour_name_2.unique_id: b"Contour 2\x00", + AquaContourContours.contour_name_3.unique_id: b"Contour 3\x00", + AquaContourContours.contour_name_4.unique_id: b"Contour 4\x00", + AquaContourContours.contour_name_5.unique_id: b"Contour 5\x00", }, id="aqua_contour", ), @@ -67,7 +67,9 @@ async def test_text_set_value( mock_client: Mock, ) -> None: """Test setting text value.""" - mock_read_char_raw[AquaContourPosition.position_name_1.uuid] = b"Position 1\x00" + mock_read_char_raw[AquaContourPosition.position_name_1.unique_id] = ( + b"Position 1\x00" + ) await setup_entry( hass, platforms=[Platform.TEXT], service_info=AQUA_CONTOUR_SERVICE_INFO diff --git a/tests/components/gardena_bluetooth/test_valve.py b/tests/components/gardena_bluetooth/test_valve.py index 1fb62c8bcba8..e31534e6f04f 100644 --- a/tests/components/gardena_bluetooth/test_valve.py +++ b/tests/components/gardena_bluetooth/test_valve.py @@ -26,11 +26,11 @@ pytestmark = pytest.mark.usefixtures("constant_advertisements") @pytest.fixture def mock_switch_chars(mock_read_char_raw): """Mock data on device.""" - mock_read_char_raw[Valve.state.uuid] = b"\x00" - mock_read_char_raw[Valve.remaining_open_time.uuid] = ( + mock_read_char_raw[Valve.state.unique_id] = b"\x00" + mock_read_char_raw[Valve.remaining_open_time.unique_id] = ( Valve.remaining_open_time.encode(0) ) - mock_read_char_raw[Valve.manual_watering_time.uuid] = ( + mock_read_char_raw[Valve.manual_watering_time.unique_id] = ( Valve.manual_watering_time.encode(1000) ) return mock_read_char_raw @@ -50,7 +50,7 @@ async def test_setup( await setup_entry(hass, mock_entry, [Platform.VALVE]) assert hass.states.get(entity_id) == snapshot - mock_switch_chars[Valve.state.uuid] = b"\x01" + mock_switch_chars[Valve.state.unique_id] = b"\x01" await scan_step() assert hass.states.get(entity_id) == snapshot