Make devices dynamic in Sensibo (#134935)

pull/135518/head
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 dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from pysensibo.model import MotionSensor, SensiboDevice
@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SensiboConfigEntry
from .const import LOGGER
from .coordinator import SensiboDataUpdateCoordinator
from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity
@ -122,32 +124,55 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
added_devices: set[str] = set()
for device_id, device_data in coordinator.data.parsed.items():
if device_data.motion_sensors:
def _add_remove_devices() -> None:
"""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(
SensiboMotionSensor(
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()
if sensor_id in new_devices
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):

View File

@ -41,10 +41,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES)
for device_id, device_data in coordinator.data.parsed.items()
)
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(
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):

View File

@ -144,12 +144,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entities = [
SensiboClimate(coordinator, device_id)
for device_id, device_data in coordinator.data.parsed.items()
]
added_devices: set[str] = set()
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.async_register_entity_service(

View File

@ -12,6 +12,7 @@ from pysensibo.model import SensiboData
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
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.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -48,6 +49,25 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
session=async_get_clientsession(hass),
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:
"""Fetch data from Sensibo."""
@ -67,4 +87,23 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
if not data.raw:
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

View File

@ -71,11 +71,23 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
SensiboNumber(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
for description in DEVICE_NUMBER_TYPES
)
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(
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):

View File

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

View File

@ -108,17 +108,27 @@ async def async_setup_entry(
"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)
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):
"""Representation of a Sensibo Select."""

View File

@ -246,25 +246,40 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
added_devices: set[str] = set()
for device_id, device_data in coordinator.data.parsed.items():
if device_data.motion_sensors:
def _add_remove_devices() -> None:
"""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(
SensiboMotionSensor(
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()
if sensor_id in new_devices
for description in MOTION_SENSOR_TYPES
)
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_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, SensorEntity):

View File

@ -84,13 +84,25 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
SensiboDeviceSwitch(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_SWITCH_TYPES
)
)
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(
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):

View File

@ -51,12 +51,24 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
SensiboDeviceUpdate(coordinator, device_id, description)
for description in DEVICE_SENSOR_TYPES
for device_id, device_data in coordinator.data.parsed.items()
if description.value_available(device_data) is not None
)
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(
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):

View File

@ -2,8 +2,14 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
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.util import NoUsernameError
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 tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
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)
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)})