From 712f5fa726aa55b44605f2736bde547a38bdb4d4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 3 Oct 2026 21:26:26 +0200 Subject: [PATCH] Add time platform to LaMetric (#181982) Co-authored-by: Claude Co-authored-by: Franck Nijhof --- homeassistant/components/lametric/const.py | 1 + homeassistant/components/lametric/icons.json | 8 + .../components/lametric/strings.json | 8 + homeassistant/components/lametric/time.py | 167 +++++++++ tests/components/lametric/conftest.py | 2 + .../components/lametric/fixtures/device.json | 2 + .../lametric/snapshots/test_diagnostics.ambr | 4 +- .../lametric/snapshots/test_time.ambr | 101 ++++++ tests/components/lametric/test_time.py | 327 ++++++++++++++++++ 9 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/lametric/time.py create mode 100644 tests/components/lametric/snapshots/test_time.ambr create mode 100644 tests/components/lametric/test_time.py diff --git a/homeassistant/components/lametric/const.py b/homeassistant/components/lametric/const.py index 0ca6762f4850..a3eb873b39c2 100644 --- a/homeassistant/components/lametric/const.py +++ b/homeassistant/components/lametric/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, Platform.UPDATE, ] diff --git a/homeassistant/components/lametric/icons.json b/homeassistant/components/lametric/icons.json index 6368b7c3b377..099097de2212 100644 --- a/homeassistant/components/lametric/icons.json +++ b/homeassistant/components/lametric/icons.json @@ -36,6 +36,14 @@ "bluetooth": { "default": "mdi:bluetooth" } + }, + "time": { + "screensaver_end_time": { + "default": "mdi:clock-end" + }, + "screensaver_start_time": { + "default": "mdi:clock-start" + } } }, "services": { diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 5148378e437f..68198bf68605 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -96,6 +96,14 @@ "bluetooth": { "name": "Bluetooth" } + }, + "time": { + "screensaver_end_time": { + "name": "Screensaver end time" + }, + "screensaver_start_time": { + "name": "Screensaver start time" + } } }, "exceptions": { diff --git a/homeassistant/components/lametric/time.py b/homeassistant/components/lametric/time.py new file mode 100644 index 000000000000..8ead0d06ccb0 --- /dev/null +++ b/homeassistant/components/lametric/time.py @@ -0,0 +1,167 @@ +"""Support for LaMetric times.""" + +from collections.abc import Callable +from dataclasses import dataclass, replace +from datetime import date, datetime, time, timedelta +from typing import TYPE_CHECKING, override + +from demetriek import ( + DisplayScreensaverModes, + DisplayScreensaverTimeBased, + ScreensaverMode, +) + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator +from .entity import LaMetricEntity +from .helpers import lametric_exception_handler + +# The SKY reports a time based screensaver mode, but is not known to support +# scheduling it. +MODEL_SKY = "sa5" + +# Both entities write the same pair of start and end time, so one at a time. +PARALLEL_UPDATES = 1 + +# Any date will do, it only carries the time arithmetic below. +TIME_ANCHOR = date(2000, 1, 1) + + +def _shift(value: time, offset: timedelta) -> time: + """Shift a time of day by an offset, wrapping around midnight.""" + return (datetime.combine(TIME_ANCHOR, value) + offset).time() + + +def _utc_offset() -> timedelta: + """Return the current offset of the Home Assistant time zone from UTC. + + The device stores the screensaver times in UTC. Converting both ways with + the same, current offset makes reading the exact inverse of writing, also + around a DST change, where going by today's date would not be. + """ + return dt_util.now().utcoffset() or timedelta() + + +@dataclass(frozen=True, kw_only=True) +class LaMetricTimeEntityDescription(TimeEntityDescription): + """Class describing LaMetric time entities.""" + + value_fn: Callable[[DisplayScreensaverTimeBased], time | None] + times_fn: Callable[ + [DisplayScreensaverTimeBased, time], tuple[time | None, time | None] + ] + + +TIMES = [ + LaMetricTimeEntityDescription( + key="screensaver_start_time", + translation_key="screensaver_start_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda mode: mode.start_time, + times_fn=lambda mode, value: (value, mode.end_time), + ), + LaMetricTimeEntityDescription( + key="screensaver_end_time", + translation_key="screensaver_end_time", + entity_category=EntityCategory.CONFIG, + value_fn=lambda mode: mode.end_time, + times_fn=lambda mode, value: (mode.start_time, value), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LaMetricConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LaMetric time based on a config entry.""" + coordinator = entry.runtime_data + if coordinator.data.model == MODEL_SKY: + return + + screensaver = coordinator.data.display.screensaver + if not screensaver or not screensaver.modes: + return + + async_add_entities( + LaMetricTimeEntity( + coordinator=coordinator, + description=description, + ) + for description in TIMES + ) + + +class LaMetricTimeEntity(LaMetricEntity, TimeEntity): + """Representation of a LaMetric time.""" + + entity_description: LaMetricTimeEntityDescription + + def __init__( + self, + coordinator: LaMetricDataUpdateCoordinator, + description: LaMetricTimeEntityDescription, + ) -> None: + """Initiate LaMetric Time.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}-{description.key}" + + @property + def _modes(self) -> DisplayScreensaverModes: + """Return the screensaver modes of the device.""" + screensaver = self.coordinator.data.display.screensaver + if TYPE_CHECKING: + assert screensaver is not None + assert screensaver.modes is not None + return screensaver.modes + + @property + @override + def native_value(self) -> time | None: + """Return the time value.""" + if (value := self.entity_description.value_fn(self._modes.time_based)) is None: + return None + + return _shift(value, _utc_offset()) + + @lametric_exception_handler + @override + async def async_set_value(self, value: time) -> None: + """Change to new time value.""" + modes = self._modes + new_time = _shift(value, -_utc_offset()) + start_time, end_time = self.entity_description.times_fn( + modes.time_based, new_time + ) + + # The device only takes both times at once, so the one left untouched + # is sent along. A device that never had its times set reports + # neither; until the other one is set too, it gets the new time. + display = await self.coordinator.lametric.display( + screensaver_mode=ScreensaverMode.TIME_BASED, + screensaver_start_time=new_time if start_time is None else start_time, + screensaver_end_time=new_time if end_time is None else end_time, + ) + + # Writing the times always switches the device to the time based mode, + # even when asked not to. Switch back to when dark if that was active; + # the times stay stored. + if modes.when_dark is not None and modes.when_dark.enabled: + display = await self.coordinator.lametric.display( + screensaver_mode=ScreensaverMode.WHEN_DARK, + screensaver_mode_enabled=True, + ) + + # The device answers with its new state, use that right away. A refresh + # requested shortly after the previous one is held back, and the next + # write would send a stale time along otherwise. + self.coordinator.async_set_updated_data( + replace(self.coordinator.data, display=display) + ) diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index 73c60a30cfde..73b0b2362dc6 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -92,6 +92,8 @@ def mock_lametric(device_fixture: str) -> Generator[MagicMock]: lametric.device.return_value = Device.from_json( load_fixture(f"{device_fixture}.json", DOMAIN) ) + # The device answers a display write with its display state. + lametric.display.return_value = lametric.device.return_value.display yield lametric diff --git a/tests/components/lametric/fixtures/device.json b/tests/components/lametric/fixtures/device.json index bf2580a0c5df..24534415b284 100644 --- a/tests/components/lametric/fixtures/device.json +++ b/tests/components/lametric/fixtures/device.json @@ -40,6 +40,8 @@ "modes": { "time_based": { "enabled": true, + "end_time": "06:30:00", + "local_end_time": "07:30:00", "local_start_time": "01:00:39", "start_time": "00:00:39" }, diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index acda5cfe4818..dbdb812a32a7 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -47,8 +47,8 @@ 'screen_off': None, 'time_based': dict({ 'enabled': True, - 'end_time': None, - 'local_end_time': None, + 'end_time': '06:30:00', + 'local_end_time': '07:30:00', 'local_start_time': '01:00:39', 'start_time': '00:00:39', }), diff --git a/tests/components/lametric/snapshots/test_time.ambr b/tests/components/lametric/snapshots/test_time.ambr new file mode 100644 index 000000000000..1492386ff635 --- /dev/null +++ b/tests/components/lametric/snapshots/test_time.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_entities[time][time.frenck_s_lametric_screensaver_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.frenck_s_lametric_screensaver_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Screensaver end time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Screensaver end time', + 'platform': 'lametric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'screensaver_end_time', + 'unique_id': 'SA110405124500W00BS9-screensaver_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[time][time.frenck_s_lametric_screensaver_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : "Frenck's LaMetric Screensaver end time", + }), + 'context': , + 'entity_id': 'time.frenck_s_lametric_screensaver_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22:30:00', + }) +# --- +# name: test_entities[time][time.frenck_s_lametric_screensaver_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.frenck_s_lametric_screensaver_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Screensaver start time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Screensaver start time', + 'platform': 'lametric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'screensaver_start_time', + 'unique_id': 'SA110405124500W00BS9-screensaver_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[time][time.frenck_s_lametric_screensaver_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : "Frenck's LaMetric Screensaver start time", + }), + 'context': , + 'entity_id': 'time.frenck_s_lametric_screensaver_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16:00:39', + }) +# --- diff --git a/tests/components/lametric/test_time.py b/tests/components/lametric/test_time.py new file mode 100644 index 000000000000..c2f1db2180da --- /dev/null +++ b/tests/components/lametric/test_time.py @@ -0,0 +1,327 @@ +"""Tests for the LaMetric time platform.""" + +from dataclasses import replace +from datetime import time +from unittest.mock import MagicMock, call + +from demetriek import LaMetricConnectionError, LaMetricError, ScreensaverMode +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TIME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_START_TIME = "time.frenck_s_lametric_screensaver_start_time" +ENTITY_END_TIME = "time.frenck_s_lametric_screensaver_end_time" + +pytestmark = pytest.mark.freeze_time("2025-01-15 12:00:00+00:00") + + +@pytest.mark.parametrize("init_integration", [Platform.TIME], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the LaMetric screensaver time entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "expected_start", "expected_end"), + [ + (ENTITY_START_TIME, time(4, 0), time(6, 30)), + (ENTITY_END_TIME, time(0, 0, 39), time(4, 0)), + ], + ids=["start_time", "end_time"], +) +@pytest.mark.usefixtures("init_integration") +async def test_set_value( + hass: HomeAssistant, + mock_lametric: MagicMock, + entity_id: str, + expected_start: time, + expected_end: time, +) -> None: + """Test setting a screensaver time sends both times, in UTC. + + The device rejects a time based write that carries only one of the times, + so the time that was not changed is sent along unaltered. + """ + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_TIME: "20:00:00"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_lametric.display.assert_called_once_with( + screensaver_mode=ScreensaverMode.TIME_BASED, + screensaver_start_time=expected_start, + screensaver_end_time=expected_end, + ) + + +@pytest.mark.parametrize( + ("now", "value", "expected"), + [ + # The evening before the switch to summer time, when the UTC date is + # already the next day. + ("2026-03-08 07:30:00+00:00", "03:30:00", time(11, 30)), + # The day of the switch itself, in the hour that does not exist. + ("2026-03-08 20:00:00+00:00", "02:30:00", time(9, 30)), + ], + ids=["evening_before_switch", "nonexistent_time"], +) +async def test_set_value_around_dst( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, + now: str, + value: str, + expected: time, +) -> None: + """Test a screensaver time reads back as set, also around a DST change.""" + freezer.move_to(now) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_START_TIME, ATTR_TIME: value}, + blocking=True, + ) + + assert mock_lametric.display.call_args.kwargs["screensaver_start_time"] == expected + + time_based = mock_lametric.device.return_value.display.screensaver.modes.time_based + time_based.start_time = expected + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_START_TIME) + assert state + assert state.state == value + + +@pytest.mark.usefixtures("init_integration") +async def test_set_values_in_quick_succession( + hass: HomeAssistant, + mock_lametric: MagicMock, +) -> None: + """Test setting both times right after each other keeps both. + + A refresh requested shortly after the previous one is held back, so the + second write has to use what the device answered to the first. + """ + display = mock_lametric.device.return_value.display + assert display.screensaver + assert display.screensaver.modes + mock_lametric.display.return_value = replace( + display, + screensaver=replace( + display.screensaver, + modes=replace( + display.screensaver.modes, + time_based=replace( + display.screensaver.modes.time_based, start_time=time(4, 0) + ), + ), + ), + ) + + for entity_id, value in ( + (ENTITY_START_TIME, "20:00:00"), + (ENTITY_END_TIME, "21:00:00"), + ): + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_TIME: value}, + blocking=True, + ) + + assert mock_lametric.display.call_args == call( + screensaver_mode=ScreensaverMode.TIME_BASED, + screensaver_start_time=time(4, 0), + screensaver_end_time=time(5, 0), + ) + + +async def test_unknown_times( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test a device that has no screensaver times configured.""" + time_based = mock_lametric.device.return_value.display.screensaver.modes.time_based + time_based.start_time = None + time_based.end_time = None + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_START_TIME) + assert state + assert state.state == STATE_UNKNOWN + + state = hass.states.get(ENTITY_END_TIME) + assert state + assert state.state == STATE_UNKNOWN + + +async def test_no_screensaver_support( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test devices that do not report a screensaver get no time entities.""" + mock_lametric.device.return_value.display.screensaver = None + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_START_TIME) is None + assert hass.states.get(ENTITY_END_TIME) is None + + +@pytest.mark.parametrize( + "entity_id", [ENTITY_START_TIME, ENTITY_END_TIME], ids=["start_time", "end_time"] +) +async def test_set_value_without_both_times( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, + entity_id: str, +) -> None: + """Test setting a screensaver time on a device that has none configured. + + The device only takes both times at once, so the new time is sent for + both, until the other one is set as well. + """ + time_based = mock_lametric.device.return_value.display.screensaver.modes.time_based + time_based.start_time = None + time_based.end_time = None + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_TIME: "20:00:00"}, + blocking=True, + ) + + mock_lametric.display.assert_called_once_with( + screensaver_mode=ScreensaverMode.TIME_BASED, + screensaver_start_time=time(4, 0), + screensaver_end_time=time(4, 0), + ) + + +async def test_set_value_keeps_when_dark( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, +) -> None: + """Test setting a screensaver time leaves the when dark mode active. + + Writing the times always switches the device to the time based mode, so + the when dark mode is switched back on afterwards. + """ + modes = mock_lametric.device.return_value.display.screensaver.modes + modes.time_based.enabled = False + modes.when_dark.enabled = True + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_START_TIME, ATTR_TIME: "20:00:00"}, + blocking=True, + ) + + assert mock_lametric.display.mock_calls == [ + call( + screensaver_mode=ScreensaverMode.TIME_BASED, + screensaver_start_time=time(4, 0), + screensaver_end_time=time(6, 30), + ), + call( + screensaver_mode=ScreensaverMode.WHEN_DARK, + screensaver_mode_enabled=True, + ), + ] + + +@pytest.mark.parametrize("device_fixture", ["device_sa5"]) +@pytest.mark.usefixtures("init_integration") +async def test_sky_has_no_times(hass: HomeAssistant) -> None: + """Test the SKY gets no screensaver time entities.""" + assert hass.states.get("time.spyfly_s_lametric_sky_screensaver_start_time") is None + assert hass.states.get("time.spyfly_s_lametric_sky_screensaver_end_time") is None + + +@pytest.mark.parametrize( + ("side_effect", "error_message", "expected_state"), + [ + ( + LaMetricError, + "Invalid response from the LaMetric device", + "16:00:39", + ), + ( + LaMetricConnectionError, + "Error communicating with the LaMetric device", + STATE_UNAVAILABLE, + ), + ], + ids=["error", "connection_error"], +) +@pytest.mark.usefixtures("init_integration") +async def test_time_errors( + hass: HomeAssistant, + mock_lametric: MagicMock, + side_effect: type[Exception], + error_message: str, + expected_state: str, +) -> None: + """Test error handling of the LaMetric times.""" + mock_lametric.display.side_effect = side_effect + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_START_TIME, ATTR_TIME: "20:00:00"}, + blocking=True, + ) + + state = hass.states.get(ENTITY_START_TIME) + assert state + assert state.state == expected_state