mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add Daylight Saving Time switch to Frontier Silicon integration (#180462)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user