Make devices dynamic in Sensibo (#134935)
parent
64752af4c2
commit
fe8cae8eb5
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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: |
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)})
|
||||
|
|
Loading…
Reference in New Issue