mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Adjust gardena to key on unique id (#178624)
This commit is contained in:
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user