diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 45db1d44f2ac..19878094edb0 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -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__) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index fe18bb926461..642360028760 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -34,6 +34,13 @@ } } }, + "entity": { + "switch": { + "dst": { + "name": "Daylight Saving Time" + } + } + }, "exceptions": { "api_error": { "message": "Failed to execute {command}: {message}" diff --git a/homeassistant/components/frontier_silicon/switch.py b/homeassistant/components/frontier_silicon/switch.py new file mode 100644 index 000000000000..85351bd6743b --- /dev/null +++ b/homeassistant/components/frontier_silicon/switch.py @@ -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)() diff --git a/tests/components/frontier_silicon/conftest.py b/tests/components/frontier_silicon/conftest.py index 63701ca2eb39..fb27b5d58a1c 100644 --- a/tests/components/frontier_silicon/conftest.py +++ b/tests/components/frontier_silicon/conftest.py @@ -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( diff --git a/tests/components/frontier_silicon/test_media_player.py b/tests/components/frontier_silicon/test_media_player.py index 9e7637a6865d..6c23c86ae5bf 100644 --- a/tests/components/frontier_silicon/test_media_player.py +++ b/tests/components/frontier_silicon/test_media_player.py @@ -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 diff --git a/tests/components/frontier_silicon/test_switch.py b/tests/components/frontier_silicon/test_switch.py new file mode 100644 index 000000000000..09457d20ec20 --- /dev/null +++ b/tests/components/frontier_silicon/test_switch.py @@ -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