Add Daylight Saving Time switch to Frontier Silicon integration (#180462)

This commit is contained in:
ashh87
2026-08-30 17:20:50 +02:00
committed by GitHub
parent 867436ed6c
commit cb844ff8ef
6 changed files with 295 additions and 11 deletions
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_WEBFSAPI_URL
PLATFORMS = [Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SWITCH]
_LOGGER = logging.getLogger(__name__)
@@ -34,6 +34,13 @@
}
}
},
"entity": {
"switch": {
"dst": {
"name": "Daylight Saving Time"
}
}
},
"exceptions": {
"api_error": {
"message": "Failed to execute {command}: {message}"
@@ -0,0 +1,115 @@
"""Support for switches on Frontier Silicon Devices (Medion, Hama, Auna,...)."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from functools import partial
import logging
from typing import Any, override
from afsapi import AFSAPI, FSConnectionError, FSNotImplementedError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FrontierSiliconConfigEntry
from .entity import FrontierSiliconEntity, fs_command_exception_wrap
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class AFSAPISwitchEntityDescription(SwitchEntityDescription):
"""Describes Frontier Silicon switch entity."""
is_on_fn: Callable[[AFSAPI], Callable[[], Coroutine[Any, Any, bool]]]
turn_on_fn: Callable[[AFSAPI], Callable[[], Coroutine[Any, Any, None]]]
turn_off_fn: Callable[[AFSAPI], Callable[[], Coroutine[Any, Any, None]]]
SWITCHES: tuple[AFSAPISwitchEntityDescription, ...] = (
AFSAPISwitchEntityDescription(
key="dst",
entity_category=EntityCategory.CONFIG,
translation_key="dst",
is_on_fn=lambda afsapi: afsapi.get_dst,
turn_on_fn=lambda afsapi: partial(afsapi.set_dst, True),
turn_off_fn=lambda afsapi: partial(afsapi.set_dst, False),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FrontierSiliconConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Frontier Silicon entity."""
afsapi = config_entry.runtime_data
# only add switch entities for nodes which exist on the target device
available_switches = []
max_tries_per_entity = 3
for description in SWITCHES:
connection_attempt_succeeded = False
num_tries = 0
while num_tries < max_tries_per_entity:
num_tries += 1
try:
_ = await description.is_on_fn(afsapi)()
except FSNotImplementedError:
# we connected OK, but the switch is not supported, so stop trying
connection_attempt_succeeded = True
break
except FSConnectionError:
# retry in case the connection error is transient
continue
available_switches.append(description)
connection_attempt_succeeded = True
break
if not connection_attempt_succeeded:
_LOGGER.warning("Could not connect to Frontier Silicon device during setup")
async_add_entities(
[
AFSAPISwitch(config_entry, afsapi, description)
for description in available_switches
],
True,
)
class AFSAPISwitch(FrontierSiliconEntity, SwitchEntity):
"""Representation of a switch on a Frontier Silicon device."""
entity_description: AFSAPISwitchEntityDescription
def __init__(
self,
config_entry: FrontierSiliconConfigEntry,
afsapi: AFSAPI,
description: AFSAPISwitchEntityDescription,
) -> None:
"""Initialize the Frontier Silicon API device."""
super().__init__(afsapi, config_entry)
self.entity_description = description
self._attr_unique_id = f"{config_entry.entry_id}-{description.key}"
@fs_command_exception_wrap
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
await self.entity_description.turn_off_fn(self.fs_device)()
@fs_command_exception_wrap
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
await self.entity_description.turn_on_fn(self.fs_device)()
@override
async def _fs_update(self) -> None:
"""Update Frontier Silicon entity."""
self._attr_is_on = await self.entity_description.is_on_fn(self.fs_device)()
@@ -56,6 +56,8 @@ def mock_afsapi() -> Generator[AsyncMock]:
client.get_volume.return_value = 3
client.get_volume_steps.return_value = 2
client.get_play_caps.return_value = PlayCaps(0)
client.get_dst.return_value = True
client.set_dst.return_value = True
modes = [
PlayerMode(
@@ -28,7 +28,8 @@ from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
ENTITY_ID = "media_player.name_of_the_device"
MEDIA_PLAYER_ENTITY_ID = "media_player.name_of_the_device"
DST_SWITCH_ENTITY_ID = "switch.name_of_the_device_daylight_saving_time"
_FULL_PLAY_CAPS = (
PlayCaps.PAUSE
@@ -81,7 +82,7 @@ async def test_async_media_previous_track_maps_errors(
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_PREVIOUS_TRACK,
{ATTR_ENTITY_ID: ENTITY_ID},
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
blocking=True,
)
@@ -103,7 +104,7 @@ async def test_async_media_caps(
await setup_integration(hass, config_entry)
state = hass.states.get(ENTITY_ID)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state.attributes[ATTR_SUPPORTED_FEATURES] == (
AFSAPIMediaPlayer._BASE_SUPPORTED_FEATURES
| MediaPlayerEntityFeature.PLAY
@@ -134,7 +135,7 @@ async def test_media_player_on(
device_entry = devices[0]
entities = er.async_entries_for_device(entity_registry, device_entry.id)
assert len(entities) == 1
assert len(entities) == 2
# Power on the device and advance time to trigger a poll
mock_afsapi.get_power.return_value = True
@@ -142,7 +143,7 @@ async def test_media_player_on(
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entities[0].entity_id).state == STATE_IDLE
assert hass.states.get(MEDIA_PLAYER_ENTITY_ID).state == STATE_IDLE
async def test_async_update_disconnect(
@@ -161,22 +162,21 @@ async def test_async_update_disconnect(
device_entry = devices[0]
entities = er.async_entries_for_device(entity_registry, device_entry.id)
assert len(entities) == 1
entity_id = entities[0].entity_id
assert len(entities) == 2
# Device starts in off state
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(MEDIA_PLAYER_ENTITY_ID).state == STATE_OFF
# Make the device raise a connection error on the next poll
mock_afsapi.get_power.side_effect = FSConnectionError
freezer.tick(timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
assert hass.states.get(MEDIA_PLAYER_ENTITY_ID).state == STATE_UNAVAILABLE
# Reset device error state
mock_afsapi.get_power.side_effect = None
freezer.tick(timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(MEDIA_PLAYER_ENTITY_ID).state == STATE_OFF
@@ -0,0 +1,160 @@
"""Test the Frontier Silicon switch entity."""
from collections.abc import Generator
from datetime import timedelta
from unittest.mock import AsyncMock
from afsapi import FSConnectionError, FSNotImplementedError
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
DST_SWITCH_ENTITY_ID = "switch.name_of_the_device_daylight_saving_time"
@pytest.mark.parametrize(
("dst_switch_side_effect", "expected_num_entities"),
[(None, 2), (FSNotImplementedError, 1)],
)
async def test_init_with_dst_availability(
hass: HomeAssistant,
config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
mock_afsapi: AsyncMock,
dst_switch_side_effect: FSNotImplementedError | None,
expected_num_entities: int,
) -> None:
"""Test integration setup notices the difference between devices which do or don't implement a DST switch."""
mock_afsapi.get_dst.side_effect = dst_switch_side_effect
await setup_integration(hass, config_entry)
devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)
assert len(devices) == 1
device_entry = devices[0]
entities = er.async_entries_for_device(entity_registry, device_entry.id)
assert len(entities) == expected_num_entities
async def test_init_device_not_ready(
hass: HomeAssistant,
config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
mock_afsapi: AsyncMock,
) -> None:
"""Test that entity isn't added if there is a connection error."""
mock_afsapi.get_dst.side_effect = FSConnectionError
await setup_integration(hass, config_entry)
devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)
assert len(devices) == 1
device_entry = devices[0]
entities = er.async_entries_for_device(entity_registry, device_entry.id)
expected_entities = 1
assert len(entities) == expected_entities
async def test_init_device_not_ready_transient_connection_error(
hass: HomeAssistant,
config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
mock_afsapi: AsyncMock,
) -> None:
"""Test that entity is added if there is a only a transient connection error."""
def transient_connection_error_generator() -> Generator[FSConnectionError | bool]:
"""Generate a transient connection error, then always yield a good result."""
yield FSConnectionError
while True:
yield True
mock_afsapi.get_dst.side_effect = transient_connection_error_generator()
await setup_integration(hass, config_entry)
devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)
assert len(devices) == 1
device_entry = devices[0]
entities = er.async_entries_for_device(entity_registry, device_entry.id)
expected_entities = 2
assert len(entities) == expected_entities
async def test_dst_switch(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_afsapi: AsyncMock,
) -> None:
"""Test turn_on and turn_off for DST switch."""
# Set up integration
await setup_integration(hass, config_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: DST_SWITCH_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
mock_afsapi.set_dst.assert_awaited_with(True)
mock_afsapi.set_dst.reset_mock()
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: DST_SWITCH_ENTITY_ID},
blocking=True,
)
await hass.async_block_till_done()
mock_afsapi.set_dst.assert_awaited_with(False)
mock_afsapi.set_dst.reset_mock()
async def test_dst_switch_get(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_afsapi: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that switch state reflects get_dst result."""
await setup_integration(hass, config_entry)
# Turn DST switch on and advance time to trigger a poll
mock_afsapi.get_dst.return_value = True
freezer.tick(timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(DST_SWITCH_ENTITY_ID).state == STATE_ON
# Turn DST switch off and advance time to trigger a poll
mock_afsapi.get_dst.return_value = False
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(DST_SWITCH_ENTITY_ID).state == STATE_OFF