Add time platform to LaMetric (#181982)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Joost Lekkerkerker
2026-10-03 21:26:26 +02:00
committed by GitHub
co-authored by Claude Franck Nijhof
parent d53398b741
commit 712f5fa726
9 changed files with 618 additions and 2 deletions
@@ -14,6 +14,7 @@ PLATFORMS = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
Platform.UPDATE,
]
@@ -36,6 +36,14 @@
"bluetooth": {
"default": "mdi:bluetooth"
}
},
"time": {
"screensaver_end_time": {
"default": "mdi:clock-end"
},
"screensaver_start_time": {
"default": "mdi:clock-start"
}
}
},
"services": {
@@ -96,6 +96,14 @@
"bluetooth": {
"name": "Bluetooth"
}
},
"time": {
"screensaver_end_time": {
"name": "Screensaver end time"
},
"screensaver_start_time": {
"name": "Screensaver start time"
}
}
},
"exceptions": {
+167
View File
@@ -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)
)
+2
View File
@@ -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
@@ -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"
},
@@ -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',
}),
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.frenck_s_lametric_screensaver_end_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: "Frenck's LaMetric Screensaver end time",
}),
'context': <ANY>,
'entity_id': 'time.frenck_s_lametric_screensaver_end_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.frenck_s_lametric_screensaver_start_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: "Frenck's LaMetric Screensaver start time",
}),
'context': <ANY>,
'entity_id': 'time.frenck_s_lametric_screensaver_start_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '16:00:39',
})
# ---
+327
View File
@@ -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