Adjust gardena to key on unique id (#178624)

This commit is contained in:
Joakim Plate
2026-08-10 16:11:52 +02:00
committed by GitHub
parent d1bd78c0ed
commit 8d589c326f
20 changed files with 238 additions and 137 deletions
@@ -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 = (
@@ -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 = (
@@ -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()
@@ -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
@@ -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 = (
@@ -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
@@ -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()
@@ -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 = (
@@ -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}"
+19 -12
View File
@@ -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
@@ -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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: '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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: '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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: '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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: '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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: '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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Mock Title Distance',
@@ -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
@@ -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
+14 -10
View File
@@ -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,
@@ -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()
@@ -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
@@ -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()
@@ -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),
]
+13 -11
View File
@@ -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
@@ -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