diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index 953848f3184..47d4317ce97 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from PySrDaliGateway import DaliGateway @@ -23,7 +24,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER from .types import DaliCenterConfigEntry, DaliCenterData -_PLATFORMS: list[Platform] = [Platform.LIGHT] +_PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SCENE] _LOGGER = logging.getLogger(__name__) @@ -48,7 +49,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - ) from exc try: - devices = await gateway.discover_devices() + devices, scenes = await asyncio.gather( + gateway.discover_devices(), + gateway.discover_scenes(), + ) except DaliGatewayError as exc: raise ConfigEntryNotReady( "Unable to discover devices from the gateway" @@ -70,6 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - entry.runtime_data = DaliCenterData( gateway=gateway, devices=devices, + scenes=scenes, ) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/sunricher_dali/entity.py b/homeassistant/components/sunricher_dali/entity.py new file mode 100644 index 00000000000..7cc0da20ca8 --- /dev/null +++ b/homeassistant/components/sunricher_dali/entity.py @@ -0,0 +1,57 @@ +"""Base entity for Sunricher DALI integration.""" + +from __future__ import annotations + +import logging + +from PySrDaliGateway import CallbackEventType, DaliObjectBase, Device + +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 + 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.""" + 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() + + +class DaliDeviceEntity(DaliCenterEntity): + """Base entity for DALI Device objects.""" + + def __init__(self, device: Device) -> None: + """Initialize device entity.""" + super().__init__(device) + self._attr_available = device.status == "online" diff --git a/homeassistant/components/sunricher_dali/light.py b/homeassistant/components/sunricher_dali/light.py index 47774bc1ac8..43079505c26 100644 --- a/homeassistant/components/sunricher_dali/light.py +++ b/homeassistant/components/sunricher_dali/light.py @@ -22,6 +22,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .entity import DaliDeviceEntity from .types import DaliCenterConfigEntry _LOGGER = logging.getLogger(__name__) @@ -45,10 +46,9 @@ async def async_setup_entry( ) -class DaliCenterLight(LightEntity): +class DaliCenterLight(DaliDeviceEntity, 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 @@ -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, @@ -111,6 +108,7 @@ class DaliCenterLight(LightEntity): 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( @@ -118,27 +116,10 @@ class DaliCenterLight(LightEntity): ) ) - 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: diff --git a/homeassistant/components/sunricher_dali/manifest.json b/homeassistant/components/sunricher_dali/manifest.json index fc3132bc027..2fa4b6c8b47 100644 --- a/homeassistant/components/sunricher_dali/manifest.json +++ b/homeassistant/components/sunricher_dali/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sunricher_dali", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["PySrDaliGateway==0.16.2"] + "requirements": ["PySrDaliGateway==0.18.0"] } diff --git a/homeassistant/components/sunricher_dali/scene.py b/homeassistant/components/sunricher_dali/scene.py new file mode 100644 index 00000000000..aef1892c5b3 --- /dev/null +++ b/homeassistant/components/sunricher_dali/scene.py @@ -0,0 +1,45 @@ +"""Support for DALI Center Scene entities.""" + +import logging +from typing import Any + +from PySrDaliGateway import Scene + +from homeassistant.components.scene import Scene as SceneEntity +from homeassistant.core import HomeAssistant +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__) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DaliCenterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up DALI Center scene entities from config entry.""" + async_add_entities(DaliCenterScene(scene) for scene in entry.runtime_data.scenes) + + +class DaliCenterScene(DaliCenterEntity, SceneEntity): + """Representation of a DALI Center Scene.""" + + def __init__(self, scene: Scene) -> None: + """Initialize the DALI scene.""" + super().__init__(scene) + self._scene = scene + self._attr_name = scene.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, scene.gw_sn)}, + ) + + async def async_activate(self, **kwargs: Any) -> None: + """Activate the DALI scene.""" + await self.hass.async_add_executor_job(self._scene.activate) diff --git a/homeassistant/components/sunricher_dali/types.py b/homeassistant/components/sunricher_dali/types.py index 39dacb69a6c..f93b192de64 100644 --- a/homeassistant/components/sunricher_dali/types.py +++ b/homeassistant/components/sunricher_dali/types.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from PySrDaliGateway import DaliGateway, Device +from PySrDaliGateway import DaliGateway, Device, Scene from homeassistant.config_entries import ConfigEntry @@ -13,6 +13,7 @@ class DaliCenterData: gateway: DaliGateway devices: list[Device] + scenes: list[Scene] type DaliCenterConfigEntry = ConfigEntry[DaliCenterData] diff --git a/requirements_all.txt b/requirements_all.txt index ba71a3dcec4..e3574e2067b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -80,7 +80,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.sunricher_dali -PySrDaliGateway==0.16.2 +PySrDaliGateway==0.18.0 # homeassistant.components.switchbot PySwitchbot==0.74.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85b7bbbfc0d..a71fab5d018 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -80,7 +80,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.sunricher_dali -PySrDaliGateway==0.16.2 +PySrDaliGateway==0.18.0 # homeassistant.components.switchbot PySwitchbot==0.74.0 diff --git a/tests/components/sunricher_dali/__init__.py b/tests/components/sunricher_dali/__init__.py index aa944423da6..9d162f03da4 100644 --- a/tests/components/sunricher_dali/__init__.py +++ b/tests/components/sunricher_dali/__init__.py @@ -16,3 +16,9 @@ def find_device_listener( raise AssertionError( f"Listener for event type {event_type} not found on device {device.dev_id}" ) + + +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) diff --git a/tests/components/sunricher_dali/conftest.py b/tests/components/sunricher_dali/conftest.py index 813a81bdd17..338e82f293b 100644 --- a/tests/components/sunricher_dali/conftest.py +++ b/tests/components/sunricher_dali/conftest.py @@ -1,8 +1,10 @@ """Common fixtures for the Sunricher DALI tests.""" from collections.abc import Generator +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 @@ -12,10 +14,73 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + Platform, ) +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +GATEWAY_SERIAL = "6A242121110E" +GATEWAY_HOST = "192.168.1.100" +GATEWAY_PORT = 1883 + +DEVICE_DATA: list[dict[str, Any]] = [ + { + "dev_id": "01010000026A242121110E", + "dev_type": "0101", + "name": "Dimmer 0000-02", + "model": "DALI DT6 Dimmable Driver", + "color_mode": "brightness", + "address": 2, + "channel": 0, + }, + { + "dev_id": "01020000036A242121110E", + "dev_type": "0102", + "name": "CCT 0000-03", + "model": "DALI DT8 Tc Dimmable Driver", + "color_mode": "color_temp", + "address": 3, + "channel": 0, + }, + { + "dev_id": "01030000046A242121110E", + "dev_type": "0103", + "name": "HS Color Light", + "model": "DALI HS Color Driver", + "color_mode": "hs", + "address": 4, + "channel": 0, + }, + { + "dev_id": "01040000056A242121110E", + "dev_type": "0104", + "name": "RGBW Light", + "model": "DALI RGBW Driver", + "color_mode": "rgbw", + "address": 5, + "channel": 0, + }, +] + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, + mock_devices: list[MagicMock], + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.sunricher_dali._PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -23,36 +88,29 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data={ - CONF_SERIAL_NUMBER: "6A242121110E", - CONF_HOST: "192.168.1.100", - CONF_PORT: 1883, + CONF_SERIAL_NUMBER: GATEWAY_SERIAL, + CONF_HOST: GATEWAY_HOST, + CONF_PORT: GATEWAY_PORT, CONF_NAME: "Test Gateway", CONF_USERNAME: "gateway_user", CONF_PASSWORD: "gateway_pass", }, - unique_id="6A242121110E", + unique_id=GATEWAY_SERIAL, title="Test Gateway", ) -def _create_mock_device( - dev_id: str, - dev_type: str, - name: str, - model: str, - color_mode: str, - gw_sn: str = "6A242121110E", -) -> MagicMock: - """Create a mock device with standard attributes.""" +def _create_mock_device(device_data: dict[str, Any]) -> MagicMock: + """Create a mock device from device data dict.""" device = MagicMock() - device.dev_id = dev_id - device.unique_id = dev_id + device.dev_id = device_data["dev_id"] + device.unique_id = device_data["dev_id"] device.status = "online" - device.dev_type = dev_type - device.name = name - device.model = model - device.gw_sn = gw_sn - device.color_mode = color_mode + device.dev_type = device_data["dev_type"] + device.name = device_data["name"] + device.model = device_data["model"] + device.gw_sn = GATEWAY_SERIAL + device.color_mode = device_data["color_mode"] device.turn_on = MagicMock() device.turn_off = MagicMock() device.read_status = MagicMock() @@ -63,43 +121,23 @@ def _create_mock_device( @pytest.fixture def mock_devices() -> list[MagicMock]: """Return mocked Device objects.""" - return [ - _create_mock_device( - "01010000026A242121110E", - "0101", - "Dimmer 0000-02", - "DALI DT6 Dimmable Driver", - "brightness", - ), - _create_mock_device( - "01020000036A242121110E", - "0102", - "CCT 0000-03", - "DALI DT8 Tc Dimmable Driver", - "color_temp", - ), - _create_mock_device( - "01030000046A242121110E", - "0103", - "HS Color Light", - "DALI HS Color Driver", - "hs", - ), - _create_mock_device( - "01040000056A242121110E", - "0104", - "RGBW Light", - "DALI RGBW Driver", - "rgbw", - ), - _create_mock_device( - "01010000026A242121110E", - "0101", - "Duplicate Dimmer", - "DALI DT6 Dimmable Driver", - "brightness", - ), - ] + devices = [_create_mock_device(data) for data in DEVICE_DATA] + devices.append(_create_mock_device(DEVICE_DATA[0])) + return devices + + +def _create_scene_device_property( + dev_type: str, brightness: int = 128, **kwargs: Any +) -> dict[str, Any]: + """Create scene device property dict with defaults.""" + return { + "is_on": True, + "brightness": brightness, + "color_temp_kelvin": kwargs.get("color_temp_kelvin"), + "hs_color": kwargs.get("hs_color"), + "rgbw_color": kwargs.get("rgbw_color"), + "white_level": kwargs.get("white_level"), + } @pytest.fixture @@ -113,8 +151,105 @@ def mock_discovery(mock_gateway: MagicMock) -> Generator[MagicMock]: yield mock_discovery +def _create_mock_scene( + scene_id: int, + name: str, + unique_id: str, + channel: int, + area_id: str, + devices: list[dict[str, Any]], + 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: dict[str, Any] = { + "unique_id": unique_id, + "id": scene_id, + "name": name, + "channel": channel, + "area_id": area_id, + "devices": devices_with_ids, + } + scene.read_scene = AsyncMock(return_value=scene_details) + scene.register_listener = MagicMock(return_value=lambda: None) + return scene + + @pytest.fixture -def mock_gateway(mock_devices: list[MagicMock]) -> Generator[MagicMock]: +def mock_scenes() -> list[MagicMock]: + """Return mocked Scene objects.""" + return [ + _create_mock_scene( + scene_id=1, + name="Living Room Evening", + unique_id=f"scene_0001_0000_{GATEWAY_SERIAL}", + channel=0, + area_id="1", + devices=[ + { + "dev_type": DEVICE_DATA[0]["dev_type"], + "channel": DEVICE_DATA[0]["channel"], + "address": DEVICE_DATA[0]["address"], + "gw_sn_obj": "", + "property": _create_scene_device_property("0101", brightness=128), + }, + { + "dev_type": DEVICE_DATA[1]["dev_type"], + "channel": DEVICE_DATA[1]["channel"], + "address": DEVICE_DATA[1]["address"], + "gw_sn_obj": "", + "property": _create_scene_device_property( + "0102", brightness=200, color_temp_kelvin=3000 + ), + }, + ], + ), + _create_mock_scene( + scene_id=2, + name="Kitchen Bright", + unique_id=f"scene_0002_0000_{GATEWAY_SERIAL}", + channel=0, + area_id="2", + devices=[ + { + "dev_type": "0401", + "channel": 0, + "address": 1, + "gw_sn_obj": "", + "property": _create_scene_device_property("0401", brightness=255), + }, + ], + ), + ] + + +@pytest.fixture +def mock_gateway( + mock_devices: list[MagicMock], mock_scenes: list[MagicMock] +) -> Generator[MagicMock]: """Return a mocked DaliGateway.""" with ( patch( @@ -126,15 +261,16 @@ def mock_gateway(mock_devices: list[MagicMock]) -> Generator[MagicMock]: ), ): mock_gateway = mock_gateway_class.return_value - mock_gateway.gw_sn = "6A242121110E" - mock_gateway.gw_ip = "192.168.1.100" - mock_gateway.port = 1883 + mock_gateway.gw_sn = GATEWAY_SERIAL + mock_gateway.gw_ip = GATEWAY_HOST + mock_gateway.port = GATEWAY_PORT mock_gateway.name = "Test Gateway" mock_gateway.username = "gateway_user" mock_gateway.passwd = "gateway_pass" mock_gateway.connect = AsyncMock() mock_gateway.disconnect = AsyncMock() mock_gateway.discover_devices = AsyncMock(return_value=mock_devices) + mock_gateway.discover_scenes = AsyncMock(return_value=mock_scenes) yield mock_gateway diff --git a/tests/components/sunricher_dali/snapshots/test_scene.ambr b/tests/components/sunricher_dali/snapshots/test_scene.ambr new file mode 100644 index 00000000000..c4812ef51b2 --- /dev/null +++ b/tests/components/sunricher_dali/snapshots/test_scene.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entities[scene.test_gateway_kitchen_bright-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.test_gateway_kitchen_bright', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Bright', + 'platform': 'sunricher_dali', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'scene_0002_0000_6A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[scene.test_gateway_kitchen_bright-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Gateway Kitchen Bright', + }), + 'context': , + 'entity_id': 'scene.test_gateway_kitchen_bright', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[scene.test_gateway_living_room_evening-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.test_gateway_living_room_evening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Evening', + 'platform': 'sunricher_dali', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'scene_0001_0000_6A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[scene.test_gateway_living_room_evening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Gateway Living Room Evening', + }), + 'context': , + 'entity_id': 'scene.test_gateway_living_room_evening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sunricher_dali/test_light.py b/tests/components/sunricher_dali/test_light.py index 0620ffceb2b..5ccf334b8f7 100644 --- a/tests/components/sunricher_dali/test_light.py +++ b/tests/components/sunricher_dali/test_light.py @@ -1,7 +1,7 @@ """Test the Sunricher DALI light platform.""" from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from PySrDaliGateway import CallbackEventType import pytest @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import find_device_listener +from . import find_device_listener, trigger_availability_callback from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform @@ -35,38 +35,12 @@ 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.""" - callback = find_device_listener(device, CallbackEventType.ONLINE_STATUS) - callback(available) - - @pytest.fixture def platforms() -> list[Platform]: """Fixture to specify which platforms to test.""" return [Platform.LIGHT] -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_gateway: MagicMock, - mock_devices: list[MagicMock], - platforms: list[Platform], -) -> MockConfigEntry: - """Set up the integration for testing.""" - mock_config_entry.add_to_hass(hass) - - with patch("homeassistant.components.sunricher_dali._PLATFORMS", platforms): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - return mock_config_entry - - @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_entities( hass: HomeAssistant, @@ -192,13 +166,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" diff --git a/tests/components/sunricher_dali/test_scene.py b/tests/components/sunricher_dali/test_scene.py new file mode 100644 index 00000000000..39fc2e9037b --- /dev/null +++ b/tests/components/sunricher_dali/test_scene.py @@ -0,0 +1,101 @@ +"""Test the Sunricher DALI scene platform.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import trigger_availability_callback + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + +TEST_SCENE_1_ENTITY_ID = "scene.test_gateway_living_room_evening" +TEST_SCENE_2_ENTITY_ID = "scene.test_gateway_kitchen_bright" +TEST_DIMMER_ENTITY_ID = "light.dimmer_0000_02" +TEST_CCT_ENTITY_ID = "light.cct_0000_03" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify which platforms to test.""" + return [Platform.SCENE] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_scenes: list[MagicMock], +) -> None: + """Test the scene entities and their attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + device_entry = device_registry.async_get_device( + identifiers={("sunricher_dali", "6A242121110E")} + ) + assert device_entry + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + state = hass.states.get(TEST_SCENE_1_ENTITY_ID) + assert state is not None + + +async def test_activate_scenes( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_scenes: list[MagicMock], +) -> None: + """Test activating single and multiple scenes.""" + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_SCENE_1_ENTITY_ID}, + blocking=True, + ) + mock_scenes[0].activate.assert_called_once() + + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [TEST_SCENE_1_ENTITY_ID, TEST_SCENE_2_ENTITY_ID]}, + blocking=True, + ) + assert mock_scenes[0].activate.call_count == 2 + mock_scenes[1].activate.assert_called_once() + + +async def test_scene_availability( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_scenes: list[MagicMock], +) -> None: + """Test scene availability changes when gateway goes offline.""" + state = hass.states.get(TEST_SCENE_1_ENTITY_ID) + assert state is not None + assert state.state != "unavailable" + + # Simulate gateway going offline + trigger_availability_callback(mock_scenes[0], False) + await hass.async_block_till_done() + + state = hass.states.get(TEST_SCENE_1_ENTITY_ID) + assert state.state == "unavailable" + + # Simulate gateway coming back online + trigger_availability_callback(mock_scenes[0], True) + await hass.async_block_till_done() + + state = hass.states.get(TEST_SCENE_1_ENTITY_ID) + assert state.state != "unavailable"