Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions homeassistant/components/sunricher_dali/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Base entity for Sunricher DALI integration."""

from __future__ import annotations

import logging

from PySrDaliGateway import CallbackEventType, DaliObjectBase, Device

Check failure on line 7 in homeassistant/components/sunricher_dali/entity.py

View workflow job for this annotation

GitHub Actions / Check pylint

E0611: No name 'DaliObjectBase' in module 'PySrDaliGateway' (no-name-in-module)

Check failure on line 7 in homeassistant/components/sunricher_dali/entity.py

View workflow job for this annotation

GitHub Actions / Check mypy

Module "PySrDaliGateway" has no attribute "DaliObjectBase" [attr-defined]

from homeassistant.core import callback
from homeassistant.helpers.entity import Entity

_LOGGER = logging.getLogger(__name__)


class DaliCenterEntity(Entity):
"""Base entity for DALI Center objects (devices, scenes, etc.)."""

_attr_has_entity_name = True
_attr_should_poll = False

def __init__(self, dali_object: DaliObjectBase) -> None:
"""Initialize base entity."""
self._dali_object = dali_object
self._attr_unique_id = dali_object.unique_id
self._unavailable_logged = False
# Set initial availability from status if available (Device has it, Scene doesn't)
if isinstance(dali_object, Device):
self._attr_available = dali_object.status == "online"
else:
self._attr_available = True

async def async_added_to_hass(self) -> None:
"""Register availability listener."""
self.async_on_remove(
self._dali_object.register_listener(
CallbackEventType.ONLINE_STATUS,
self._handle_availability,
)
)

@callback
def _handle_availability(self, available: bool) -> None:
"""Handle availability changes."""
# Log availability transitions
if not available and not self._unavailable_logged:
_LOGGER.info("Entity %s became unavailable", self.entity_id)
self._unavailable_logged = True
elif available and self._unavailable_logged:
_LOGGER.info("Entity %s is back online", self.entity_id)
self._unavailable_logged = False

self._attr_available = available
self.schedule_update_ha_state()
27 changes: 4 additions & 23 deletions homeassistant/components/sunricher_dali/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from .const import DOMAIN, MANUFACTURER
from .entity import DaliCenterEntity
from .types import DaliCenterConfigEntry

_LOGGER = logging.getLogger(__name__)
Expand All @@ -45,10 +46,9 @@ async def async_setup_entry(
)


class DaliCenterLight(LightEntity):
class DaliCenterLight(DaliCenterEntity, LightEntity):
"""Representation of a Sunricher DALI Light."""

_attr_has_entity_name = True
_attr_name = None
_attr_is_on: bool | None = None
_attr_brightness: int | None = None
Expand All @@ -60,11 +60,8 @@ class DaliCenterLight(LightEntity):

def __init__(self, light: Device) -> None:
"""Initialize the light entity."""

super().__init__(light)
self._light = light
self._unavailable_logged = False
self._attr_unique_id = light.unique_id
self._attr_available = light.status == "online"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, light.dev_id)},
name=light.name,
Expand Down Expand Up @@ -111,34 +108,18 @@ async def async_turn_off(self, **kwargs: Any) -> None:

async def async_added_to_hass(self) -> None:
"""Handle entity addition to Home Assistant."""
await super().async_added_to_hass()

self.async_on_remove(
self._light.register_listener(
CallbackEventType.LIGHT_STATUS, self._handle_device_update
)
)

self.async_on_remove(
self._light.register_listener(
CallbackEventType.ONLINE_STATUS, self._handle_availability
)
)

# read_status() only queues a request on the gateway and relies on the
# current event loop via call_later, so it must run in the loop thread.
self._light.read_status()

@callback
def _handle_availability(self, available: bool) -> None:
self._attr_available = available
if not available and not self._unavailable_logged:
_LOGGER.info("Light %s became unavailable", self._attr_unique_id)
self._unavailable_logged = True
elif available and self._unavailable_logged:
_LOGGER.info("Light %s is back online", self._attr_unique_id)
self._unavailable_logged = False
self.schedule_update_ha_state()

@callback
def _handle_device_update(self, status: LightStatus) -> None:
if status.get("is_on") is not None:
Expand Down
94 changes: 21 additions & 73 deletions homeassistant/components/sunricher_dali/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
import logging
from typing import Any

from PySrDaliGateway import CallbackEventType, Scene
from PySrDaliGateway.helper import gen_device_unique_id, gen_group_unique_id
from propcache.api import cached_property
from PySrDaliGateway import Scene

from homeassistant.components.scene import Scene as SceneEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from .const import DOMAIN
from .entity import DaliCenterEntity
from .types import DaliCenterConfigEntry

_LOGGER = logging.getLogger(__name__)
Expand All @@ -26,89 +27,36 @@
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up DALI Center scene entities from config entry."""
scenes = entry.runtime_data.scenes
async_add_entities(DaliCenterScene(scene) for scene in entry.runtime_data.scenes)

scene_entities: list[DaliCenterScene] = []
for scene in scenes:
try:
scene_details = await scene.read_scene()
scene_entities.append(DaliCenterScene(scene, scene_details))
except (OSError, ValueError, KeyError):
_LOGGER.exception(
"Failed to read scene details for %s, skipping scene",
scene.name,
)

if scene_entities:
async_add_entities(scene_entities)


class DaliCenterScene(SceneEntity):
class DaliCenterScene(DaliCenterEntity, SceneEntity):
"""Representation of a DALI Center Scene."""

_attr_has_entity_name = True
_attr_available = True

def __init__(self, scene: Scene, scene_details: dict[str, Any]) -> None:
def __init__(self, scene: Scene) -> None:
"""Initialize the DALI scene."""

super().__init__(scene)
self._scene = scene
self._attr_name = scene.name
self._attr_unique_id = scene.unique_id
self._scene_details = scene_details
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, scene.gw_sn)},
)
self._attr_extra_state_attributes = {
"gateway_sn": scene.gw_sn,
"scene_id": scene.scene_id,
"area_id": scene_details["area_id"],
"channel": scene_details["channel"],
"entity_id": [],
}

async def async_added_to_hass(self) -> None:
"""Handle entity addition to Home Assistant."""

self.async_on_remove(
self._scene.register_listener(
CallbackEventType.ONLINE_STATUS, self._handle_availability
)
)

self._update_entity_mappings()

@callback
def _handle_availability(self, available: bool) -> None:
"""Handle gateway availability changes."""
self._attr_available = available
self.schedule_update_ha_state()

def _update_entity_mappings(self) -> None:
"""Update entity ID mappings in extra_state_attributes."""
@cached_property
def extra_state_attributes(self) -> dict[str, list[str]]:
"""Return the scene state attributes."""
ent_reg = er.async_get(self.hass)
mapped_entities: list[str] = []

for device in self._scene_details["devices"]:
if device["dev_type"] == "0401":
device_unique_id = gen_group_unique_id(
device["address"],
device["channel"],
self._scene.gw_sn,
)
else:
device_unique_id = gen_device_unique_id(
device["dev_type"],
device["channel"],
device["address"],
self._scene.gw_sn,
return {
"entity_id": [
entity_id
for device in self._scene.devices

Check failure on line 52 in homeassistant/components/sunricher_dali/scene.py

View workflow job for this annotation

GitHub Actions / Check mypy

"Scene" has no attribute "devices" [attr-defined]
if (
entity_id := ent_reg.async_get_entity_id(
"light", DOMAIN, device["unique_id"]
)
)
entity_id = ent_reg.async_get_entity_id("light", DOMAIN, device_unique_id)

if entity_id:
mapped_entities.append(entity_id)

self._attr_extra_state_attributes["entity_id"] = mapped_entities
]
}

async def async_activate(self, **kwargs: Any) -> None:
"""Activate the DALI scene."""
Expand Down
21 changes: 19 additions & 2 deletions tests/components/sunricher_dali/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

from PySrDaliGateway.helper import gen_device_unique_id, gen_group_unique_id
import pytest

from homeassistant.components.sunricher_dali.const import CONF_SERIAL_NUMBER, DOMAIN
Expand Down Expand Up @@ -140,21 +141,37 @@ def _create_mock_scene(
gw_sn: str = GATEWAY_SERIAL,
) -> MagicMock:
"""Create a mock scene with standard attributes."""
devices_with_ids: list[dict[str, Any]] = []
for device in devices:
device_with_id = dict(device)
device_with_id["unique_id"] = (
gen_group_unique_id(device["address"], device["channel"], gw_sn)
if device["dev_type"] == "0401"
else gen_device_unique_id(
device["dev_type"],
device["channel"],
device["address"],
gw_sn,
)
)
devices_with_ids.append(device_with_id)

scene = MagicMock()
scene.scene_id = scene_id
scene.name = name
scene.unique_id = unique_id
scene.gw_sn = gw_sn
scene.channel = channel
scene.activate = MagicMock()
scene.devices = devices_with_ids

scene_details = {
scene_details: dict[str, Any] = {
"unique_id": unique_id,
"id": scene_id,
"name": name,
"channel": channel,
"area_id": area_id,
"devices": devices,
"devices": devices_with_ids,
}
scene.read_scene = AsyncMock(return_value=scene_details)
scene.register_listener = MagicMock(return_value=lambda: None)
Expand Down
8 changes: 0 additions & 8 deletions tests/components/sunricher_dali/snapshots/test_scene.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,9 @@
# name: test_entities[scene.test_gateway_kitchen_bright-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'area_id': '2',
'channel': 0,
'entity_id': list([
]),
'friendly_name': 'Test Gateway Kitchen Bright',
'gateway_sn': '6A242121110E',
'scene_id': 2,
}),
'context': <ANY>,
'entity_id': 'scene.test_gateway_kitchen_bright',
Expand Down Expand Up @@ -91,13 +87,9 @@
# name: test_entities[scene.test_gateway_living_room_evening-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'area_id': '1',
'channel': 0,
'entity_id': list([
]),
'friendly_name': 'Test Gateway Living Room Evening',
'gateway_sn': '6A242121110E',
'scene_id': 1,
}),
'context': <ANY>,
'entity_id': 'scene.test_gateway_living_room_evening',
Expand Down
12 changes: 5 additions & 7 deletions tests/components/sunricher_dali/test_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,8 @@ def _trigger_light_status_callback(
callback(status)


def _trigger_availability_callback(
device: MagicMock, device_id: str, available: bool
) -> None:
"""Trigger the availability callbacks registered on the device mock."""
def _trigger_availability_callback(device: MagicMock, available: bool) -> None:
"""Trigger availability callbacks registered on the device mock."""
callback = find_device_listener(device, CallbackEventType.ONLINE_STATUS)
callback(available)

Expand Down Expand Up @@ -192,13 +190,13 @@ async def test_device_availability(
init_integration: MockConfigEntry,
mock_devices: list[MagicMock],
) -> None:
"""Test device availability changes."""
_trigger_availability_callback(mock_devices[0], TEST_DIMMER_DEVICE_ID, False)
"""Test availability changes are reflected in entity state."""
_trigger_availability_callback(mock_devices[0], False)
await hass.async_block_till_done()
assert (state := hass.states.get(TEST_DIMMER_ENTITY_ID))
assert state.state == "unavailable"

_trigger_availability_callback(mock_devices[0], TEST_DIMMER_DEVICE_ID, True)
_trigger_availability_callback(mock_devices[0], True)
await hass.async_block_till_done()
assert (state := hass.states.get(TEST_DIMMER_ENTITY_ID))
assert state.state != "unavailable"
Loading
Loading