diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index 9ed8ec33578..893b596e11d 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -25,7 +25,12 @@ 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.BUTTON, Platform.LIGHT, Platform.SCENE] +_PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.LIGHT, + Platform.SCENE, + Platform.SENSOR, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sunricher_dali/sensor.py b/homeassistant/components/sunricher_dali/sensor.py new file mode 100644 index 00000000000..19dc58d5168 --- /dev/null +++ b/homeassistant/components/sunricher_dali/sensor.py @@ -0,0 +1,121 @@ +"""Platform for Sunricher DALI sensor entities.""" + +from __future__ import annotations + +import logging + +from PySrDaliGateway import CallbackEventType, Device +from PySrDaliGateway.helper import is_illuminance_sensor +from PySrDaliGateway.types import IlluminanceStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import LIGHT_LUX +from homeassistant.core import HomeAssistant, callback +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__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DaliCenterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sunricher DALI sensor entities from config entry.""" + devices = entry.runtime_data.devices + + entities: list[SensorEntity] = [ + SunricherDaliIlluminanceSensor(device) + for device in devices + if is_illuminance_sensor(device.dev_type) + ] + + if entities: + async_add_entities(entities) + + +class SunricherDaliIlluminanceSensor(DaliDeviceEntity, SensorEntity): + """Representation of a Sunricher DALI Illuminance Sensor.""" + + _attr_device_class = SensorDeviceClass.ILLUMINANCE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = LIGHT_LUX + _attr_name = None + + def __init__(self, device: Device) -> None: + """Initialize the illuminance sensor.""" + super().__init__(device) + self._device = device + self._illuminance_value: float | None = None + self._sensor_enabled: bool = True + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.dev_id)}, + name=device.name, + manufacturer=MANUFACTURER, + model=device.model, + via_device=(DOMAIN, device.gw_sn), + ) + + @property + def native_value(self) -> float | None: + """Return the native value, or None if sensor is disabled.""" + if not self._sensor_enabled: + return None + return self._illuminance_value + + 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._device.register_listener( + CallbackEventType.ILLUMINANCE_STATUS, self._handle_illuminance_status + ) + ) + + self.async_on_remove( + self._device.register_listener( + CallbackEventType.SENSOR_ON_OFF, self._handle_sensor_on_off + ) + ) + + self._device.read_status() + + @callback + def _handle_illuminance_status(self, status: IlluminanceStatus) -> None: + """Handle illuminance status updates.""" + illuminance_value = status["illuminance_value"] + is_valid = status["is_valid"] + + if not is_valid: + _LOGGER.debug( + "Illuminance value is not valid for device %s: %s lux", + self._device.dev_id, + illuminance_value, + ) + return + + self._illuminance_value = illuminance_value + self.schedule_update_ha_state() + + @callback + def _handle_sensor_on_off(self, on_off: bool) -> None: + """Handle sensor on/off updates.""" + self._sensor_enabled = on_off + _LOGGER.debug( + "Illuminance sensor enable state for device %s updated to: %s", + self._device.dev_id, + on_off, + ) + self.schedule_update_ha_state() diff --git a/tests/components/sunricher_dali/conftest.py b/tests/components/sunricher_dali/conftest.py index 338e82f293b..f3f2ed489b2 100644 --- a/tests/components/sunricher_dali/conftest.py +++ b/tests/components/sunricher_dali/conftest.py @@ -63,6 +63,16 @@ DEVICE_DATA: list[dict[str, Any]] = [ }, ] +ILLUMINANCE_SENSOR_DATA: dict[str, Any] = { + "dev_id": "02020000206A242121110E", + "dev_type": "0202", + "name": "Illuminance Sensor 0000-20", + "model": "DALI Illuminance Sensor", + "color_mode": None, + "address": 20, + "channel": 0, +} + @pytest.fixture async def init_integration( @@ -126,6 +136,12 @@ def mock_devices() -> list[MagicMock]: return devices +@pytest.fixture +def mock_illuminance_device() -> MagicMock: + """Return a mocked illuminance sensor device.""" + return _create_mock_device(ILLUMINANCE_SENSOR_DATA) + + def _create_scene_device_property( dev_type: str, brightness: int = 128, **kwargs: Any ) -> dict[str, Any]: diff --git a/tests/components/sunricher_dali/snapshots/test_sensor.ambr b/tests/components/sunricher_dali/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fead384b976 --- /dev/null +++ b/tests/components/sunricher_dali/snapshots/test_sensor.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_setup_entry[sensor.illuminance_sensor_0000_20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.illuminance_sensor_0000_20', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'sunricher_dali', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02020000206A242121110E', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_setup_entry[sensor.illuminance_sensor_0000_20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Illuminance Sensor 0000-20', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.illuminance_sensor_0000_20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sunricher_dali/test_sensor.py b/tests/components/sunricher_dali/test_sensor.py new file mode 100644 index 00000000000..0f6ba6aacba --- /dev/null +++ b/tests/components/sunricher_dali/test_sensor.py @@ -0,0 +1,131 @@ +"""Test the Sunricher DALI sensor platform.""" + +from unittest.mock import MagicMock + +from PySrDaliGateway import CallbackEventType +import pytest + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import find_device_listener, trigger_availability_callback + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + +ENTITY_ID = "sensor.illuminance_sensor_0000_20" + + +@pytest.fixture +def mock_devices(mock_illuminance_device: MagicMock) -> list[MagicMock]: + """Override mock_devices to use illuminance sensor only.""" + return [mock_illuminance_device] + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify which platforms to test.""" + return [Platform.SENSOR] + + +@pytest.mark.usefixtures("init_integration") +async def test_setup_entry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test that async_setup_entry correctly creates sensor entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert len(entity_entries) == 1 + assert entity_entries[0].entity_id == ENTITY_ID + + +@pytest.mark.usefixtures("init_integration") +async def test_illuminance_callback( + hass: HomeAssistant, + mock_illuminance_device: MagicMock, +) -> None: + """Test IlluminanceSensor handles valid and invalid values correctly.""" + callback = find_device_listener( + mock_illuminance_device, CallbackEventType.ILLUMINANCE_STATUS + ) + + # Valid value should update state + callback({"illuminance_value": 500.0, "is_valid": True}) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert float(state.state) == 500.0 + + # Invalid value should be ignored + callback({"illuminance_value": 9999.0, "is_valid": False}) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert float(state.state) == 500.0 + + +@pytest.mark.usefixtures("init_integration") +async def test_sensor_on_off( + hass: HomeAssistant, + mock_illuminance_device: MagicMock, +) -> None: + """Test IlluminanceSensor handles sensor on/off callback correctly.""" + illuminance_callback = find_device_listener( + mock_illuminance_device, CallbackEventType.ILLUMINANCE_STATUS + ) + illuminance_callback({"illuminance_value": 250.0, "is_valid": True}) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert float(state.state) == 250.0 + + on_off_callback = find_device_listener( + mock_illuminance_device, CallbackEventType.SENSOR_ON_OFF + ) + + # Turn off sensor -> state becomes unknown + on_off_callback(False) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + # Turn on sensor -> restore previous value + on_off_callback(True) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert float(state.state) == 250.0 + + +@pytest.mark.usefixtures("init_integration") +async def test_availability( + hass: HomeAssistant, + mock_illuminance_device: MagicMock, +) -> None: + """Test availability changes are reflected in sensor entity state.""" + trigger_availability_callback(mock_illuminance_device, False) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + trigger_availability_callback(mock_illuminance_device, True) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE