Make devices dynamic in Sensibo (#134935)

This commit is contained in:
G Johansson 2025-01-09 09:02:14 +01:00 committed by GitHub
parent 64752af4c2
commit fe8cae8eb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 290 additions and 67 deletions

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pysensibo.model import MotionSensor, SensiboDevice from pysensibo.model import MotionSensor, SensiboDevice
@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SensiboConfigEntry from . import SensiboConfigEntry
from .const import LOGGER
from .coordinator import SensiboDataUpdateCoordinator from .coordinator import SensiboDataUpdateCoordinator
from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity
@ -122,32 +124,55 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] added_devices: set[str] = set()
for device_id, device_data in coordinator.data.parsed.items(): def _add_remove_devices() -> None:
if device_data.motion_sensors: """Handle additions of devices and sensors."""
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
nonlocal added_devices
new_devices, remove_devices, added_devices = coordinator.get_devices(
added_devices
)
if LOGGER.isEnabledFor(logging.DEBUG):
LOGGER.debug(
"New devices: %s, Removed devices: %s, Existing devices: %s",
new_devices,
remove_devices,
added_devices,
)
if new_devices:
entities.extend( entities.extend(
SensiboMotionSensor( SensiboMotionSensor(
coordinator, device_id, sensor_id, sensor_data, description coordinator, device_id, sensor_id, sensor_data, description
) )
for device_id, device_data in coordinator.data.parsed.items()
if device_data.motion_sensors
for sensor_id, sensor_data in device_data.motion_sensors.items() for sensor_id, sensor_data in device_data.motion_sensors.items()
if sensor_id in new_devices
for description in MOTION_SENSOR_TYPES for description in MOTION_SENSOR_TYPES
) )
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for description in MOTION_DEVICE_SENSOR_TYPES
for device_id, device_data in coordinator.data.parsed.items()
if device_data.motion_sensors
)
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SENSOR_TYPES
)
)
async_add_entities(entities) entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_data.motion_sensors and device_id in new_devices
for description in MOTION_DEVICE_SENSOR_TYPES
)
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SENSOR_TYPES
)
)
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity): class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity):

View File

@ -41,10 +41,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
async_add_entities( added_devices: set[str] = set()
SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES)
for device_id, device_data in coordinator.data.parsed.items() def _add_remove_devices() -> None:
) """Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES)
for device_id in coordinator.data.parsed
if device_id in new_devices
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity): class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity):

View File

@ -144,12 +144,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
entities = [ added_devices: set[str] = set()
SensiboClimate(coordinator, device_id)
for device_id, device_data in coordinator.data.parsed.items()
]
async_add_entities(entities) def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboClimate(coordinator, device_id)
for device_id in coordinator.data.parsed
if device_id in new_devices
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service( platform.async_register_entity_service(

View File

@ -12,6 +12,7 @@ from pysensibo.model import SensiboData
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -48,6 +49,25 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
session=async_get_clientsession(hass), session=async_get_clientsession(hass),
timeout=TIMEOUT, timeout=TIMEOUT,
) )
self.previous_devices: set[str] = set()
def get_devices(
self, added_devices: set[str]
) -> tuple[set[str], set[str], set[str]]:
"""Addition and removal of devices."""
data = self.data
motion_sensors = {
sensor_id
for device_data in data.parsed.values()
if device_data.motion_sensors
for sensor_id in device_data.motion_sensors
}
devices: set[str] = set(data.parsed)
new_devices: set[str] = motion_sensors | devices - added_devices
remove_devices = added_devices - devices - motion_sensors
added_devices = (added_devices - remove_devices) | new_devices
return (new_devices, remove_devices, added_devices)
async def _async_update_data(self) -> SensiboData: async def _async_update_data(self) -> SensiboData:
"""Fetch data from Sensibo.""" """Fetch data from Sensibo."""
@ -67,4 +87,23 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
if not data.raw: if not data.raw:
raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_data") raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_data")
current_devices = set(data.parsed)
for device_data in data.parsed.values():
if device_data.motion_sensors:
for motion_sensor_id in device_data.motion_sensors:
current_devices.add(motion_sensor_id)
if stale_devices := self.previous_devices - current_devices:
LOGGER.debug("Removing stale devices: %s", stale_devices)
device_registry = dr.async_get(self.hass)
for _id in stale_devices:
device = device_registry.async_get_device(identifiers={(DOMAIN, _id)})
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_devices = current_devices
return data return data

View File

@ -71,11 +71,23 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
async_add_entities( added_devices: set[str] = set()
SensiboNumber(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items() def _add_remove_devices() -> None:
for description in DEVICE_NUMBER_TYPES """Handle additions of devices and sensors."""
) nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboNumber(coordinator, device_id, description)
for device_id in coordinator.data.parsed
for description in DEVICE_NUMBER_TYPES
if device_id in new_devices
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity): class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity):

View File

@ -54,7 +54,7 @@ rules:
entity-category: done entity-category: done
entity-disabled-by-default: done entity-disabled-by-default: done
discovery: done discovery: done
stale-devices: todo stale-devices: done
diagnostics: diagnostics:
status: done status: done
comment: | comment: |
@ -62,7 +62,7 @@ rules:
exception-translations: done exception-translations: done
icon-translations: done icon-translations: done
reconfiguration-flow: done reconfiguration-flow: done
dynamic-devices: todo dynamic-devices: done
discovery-update-info: discovery-update-info:
status: exempt status: exempt
comment: | comment: |

View File

@ -108,17 +108,27 @@ async def async_setup_entry(
"entity": entity_id, "entity": entity_id,
}, },
) )
entities.extend(
[
SensiboSelect(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
for description in DEVICE_SELECT_TYPES
if description.key in device_data.full_features
]
)
async_add_entities(entities) async_add_entities(entities)
added_devices: set[str] = set()
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboSelect(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DEVICE_SELECT_TYPES
if description.key in device_data.full_features
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity):
"""Representation of a Sensibo Select.""" """Representation of a Sensibo Select."""

View File

@ -246,25 +246,40 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] added_devices: set[str] = set()
for device_id, device_data in coordinator.data.parsed.items(): def _add_remove_devices() -> None:
if device_data.motion_sensors: """Handle additions of devices and sensors."""
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
nonlocal added_devices
new_devices, remove_devices, added_devices = coordinator.get_devices(
added_devices
)
if new_devices:
entities.extend( entities.extend(
SensiboMotionSensor( SensiboMotionSensor(
coordinator, device_id, sensor_id, sensor_data, description coordinator, device_id, sensor_id, sensor_data, description
) )
for device_id, device_data in coordinator.data.parsed.items()
if device_data.motion_sensors
for sensor_id, sensor_data in device_data.motion_sensors.items() for sensor_id, sensor_data in device_data.motion_sensors.items()
if sensor_id in new_devices
for description in MOTION_SENSOR_TYPES for description in MOTION_SENSOR_TYPES
) )
entities.extend( entities.extend(
SensiboDeviceSensor(coordinator, device_id, description) SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items() for device_id, device_data in coordinator.data.parsed.items()
for description in DESCRIPTION_BY_MODELS.get( if device_id in new_devices
device_data.model, DEVICE_SENSOR_TYPES for description in DESCRIPTION_BY_MODELS.get(
) device_data.model, DEVICE_SENSOR_TYPES
) )
async_add_entities(entities) )
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity):

View File

@ -84,13 +84,25 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
async_add_entities( added_devices: set[str] = set()
SensiboDeviceSwitch(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items() def _add_remove_devices() -> None:
for description in DESCRIPTION_BY_MODELS.get( """Handle additions of devices and sensors."""
device_data.model, DEVICE_SWITCH_TYPES nonlocal added_devices
) new_devices, _, added_devices = coordinator.get_devices(added_devices)
)
if new_devices:
async_add_entities(
SensiboDeviceSwitch(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SWITCH_TYPES
)
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity):

View File

@ -51,12 +51,24 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
async_add_entities( added_devices: set[str] = set()
SensiboDeviceUpdate(coordinator, device_id, description)
for description in DEVICE_SENSOR_TYPES def _add_remove_devices() -> None:
for device_id, device_data in coordinator.data.parsed.items() """Handle additions of devices and sensors."""
if description.value_available(device_data) is not None nonlocal added_devices
) new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboDeviceUpdate(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DEVICE_SENSOR_TYPES
if description.value_available(device_data) is not None
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
class SensiboDeviceUpdate(SensiboDeviceBaseEntity, UpdateEntity): class SensiboDeviceUpdate(SensiboDeviceBaseEntity, UpdateEntity):

View File

@ -2,8 +2,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pysensibo.model import SensiboData
import pytest
from homeassistant.components.sensibo.const import DOMAIN from homeassistant.components.sensibo.const import DOMAIN
from homeassistant.components.sensibo.util import NoUsernameError from homeassistant.components.sensibo.util import NoUsernameError
from homeassistant.config_entries import SOURCE_USER, ConfigEntry, ConfigEntryState from homeassistant.config_entries import SOURCE_USER, ConfigEntry, ConfigEntryState
@ -13,7 +19,7 @@ from homeassistant.setup import async_setup_component
from . import ENTRY_CONFIG from . import ENTRY_CONFIG
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import WebSocketGenerator from tests.typing import WebSocketGenerator
@ -103,3 +109,73 @@ async def test_device_remove_devices(
) )
response = await client.remove_device(dead_device_entry.id, load_int.entry_id) response = await client.remove_device(dead_device_entry.id, load_int.entry_id)
assert response["success"] assert response["success"]
@pytest.mark.parametrize(
("entity_id", "device_ids"),
[
# Device is ABC999111
("climate.hallway", ["ABC999111"]),
("binary_sensor.hallway_filter_clean_required", ["ABC999111"]),
("number.hallway_temperature_calibration", ["ABC999111"]),
("sensor.hallway_filter_last_reset", ["ABC999111"]),
("update.hallway_firmware", ["ABC999111"]),
# Device is AABBCC belonging to device ABC999111
("binary_sensor.hallway_motion_sensor_motion", ["ABC999111", "AABBCC"]),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_automatic_device_addition_and_removal(
hass: HomeAssistant,
load_int: ConfigEntry,
mock_client: MagicMock,
get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]],
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
entity_id: str,
device_ids: list[str],
) -> None:
"""Test for automatic device addition and removal."""
state = hass.states.get(entity_id)
assert state
assert entity_registry.async_get(entity_id)
for device_id in device_ids:
assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)})
# Remove one of the devices
new_device_list = [
device for device in get_data[2]["result"] if device["id"] != device_ids[0]
]
mock_client.async_get_devices.return_value = {
"status": "success",
"result": new_device_list,
}
new_data = {k: v for k, v in get_data[0].parsed.items() if k != device_ids[0]}
new_raw = mock_client.async_get_devices.return_value["result"]
mock_client.async_get_devices_data.return_value = SensiboData(new_raw, new_data)
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert not state
assert not entity_registry.async_get(entity_id)
for device_id in device_ids:
assert not device_registry.async_get_device(identifiers={(DOMAIN, device_id)})
# Add the device back
mock_client.async_get_devices.return_value = get_data[2]
mock_client.async_get_devices_data.return_value = get_data[0]
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert entity_registry.async_get(entity_id)
for device_id in device_ids:
assert device_registry.async_get_device(identifiers={(DOMAIN, device_id)})