From 3725e498ff964935b6e10924f836744452f84f4a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Apr 2026 19:50:48 +0200 Subject: [PATCH] Add switch platform to Fumis integration (#169096) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: frenck <195327+frenck@users.noreply.github.com> --- homeassistant/components/fumis/__init__.py | 1 + homeassistant/components/fumis/icons.json | 8 + homeassistant/components/fumis/strings.json | 8 + homeassistant/components/fumis/switch.py | 100 +++++++++++++ .../fumis/snapshots/test_switch.ambr | 101 +++++++++++++ tests/components/fumis/test_switch.py | 141 ++++++++++++++++++ 6 files changed, 359 insertions(+) create mode 100644 homeassistant/components/fumis/switch.py create mode 100644 tests/components/fumis/snapshots/test_switch.ambr create mode 100644 tests/components/fumis/test_switch.py diff --git a/homeassistant/components/fumis/__init__.py b/homeassistant/components/fumis/__init__.py index e04b1b1527d2..61ebeb789a68 100644 --- a/homeassistant/components/fumis/__init__.py +++ b/homeassistant/components/fumis/__init__.py @@ -13,6 +13,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/fumis/icons.json b/homeassistant/components/fumis/icons.json index f8298d2b1da6..54cee65a556f 100644 --- a/homeassistant/components/fumis/icons.json +++ b/homeassistant/components/fumis/icons.json @@ -99,6 +99,14 @@ "wifi_signal_strength": { "default": "mdi:wifi" } + }, + "switch": { + "eco_mode": { + "default": "mdi:leaf" + }, + "timer": { + "default": "mdi:timer" + } } } } diff --git a/homeassistant/components/fumis/strings.json b/homeassistant/components/fumis/strings.json index 8332b1f5d3e7..54d615ad9a49 100644 --- a/homeassistant/components/fumis/strings.json +++ b/homeassistant/components/fumis/strings.json @@ -188,6 +188,14 @@ "wifi_signal_strength": { "name": "Wi-Fi signal strength" } + }, + "switch": { + "eco_mode": { + "name": "Eco mode" + }, + "timer": { + "name": "Timer" + } } }, "exceptions": { diff --git a/homeassistant/components/fumis/switch.py b/homeassistant/components/fumis/switch.py new file mode 100644 index 000000000000..233743fc6aea --- /dev/null +++ b/homeassistant/components/fumis/switch.py @@ -0,0 +1,100 @@ +"""Support for Fumis switch entities.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from fumis import Fumis, FumisInfo + +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 .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator +from .entity import FumisEntity +from .helpers import fumis_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class FumisSwitchEntityDescription(SwitchEntityDescription): + """Describes a Fumis switch entity.""" + + has_fn: Callable[[FumisInfo], bool] = lambda _: True + is_on_fn: Callable[[FumisInfo], bool] + turn_on_fn: Callable[[Fumis], Awaitable[Any]] + turn_off_fn: Callable[[Fumis], Awaitable[Any]] + + +SWITCHES: tuple[FumisSwitchEntityDescription, ...] = ( + FumisSwitchEntityDescription( + key="eco_mode", + translation_key="eco_mode", + entity_category=EntityCategory.CONFIG, + has_fn=lambda data: data.controller.eco_mode is not None, + is_on_fn=lambda data: ( + data.controller.eco_mode.enabled if data.controller.eco_mode else False + ), + turn_on_fn=lambda client: client.set_eco_mode(enabled=True), + turn_off_fn=lambda client: client.set_eco_mode(enabled=False), + ), + FumisSwitchEntityDescription( + key="timer", + translation_key="timer", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda data: data.controller.timer_enable, + turn_on_fn=lambda client: client.set_timer(enabled=True), + turn_off_fn=lambda client: client.set_timer(enabled=False), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FumisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fumis switch entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + FumisSwitchEntity(coordinator=coordinator, description=description) + for description in SWITCHES + if description.has_fn(coordinator.data) + ) + + +class FumisSwitchEntity(FumisEntity, SwitchEntity): + """Defines a Fumis switch entity.""" + + entity_description: FumisSwitchEntityDescription + + def __init__( + self, + coordinator: FumisDataUpdateCoordinator, + description: FumisSwitchEntityDescription, + ) -> None: + """Initialize the Fumis switch entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + @fumis_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.entity_description.turn_on_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() + + @fumis_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.entity_description.turn_off_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() diff --git a/tests/components/fumis/snapshots/test_switch.ambr b/tests/components/fumis/snapshots/test_switch.ambr new file mode 100644 index 000000000000..fccb57099bd6 --- /dev/null +++ b/tests/components/fumis/snapshots/test_switch.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_switches[switch][switch.clou_duo_eco_mode-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': 'switch', + 'entity_category': , + 'entity_id': 'switch.clou_duo_eco_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Eco mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eco mode', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'eco_mode', + 'unique_id': 'aa:bb:cc:dd:ee:ff_eco_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch][switch.clou_duo_eco_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clou Duo Eco mode', + }), + 'context': , + 'entity_id': 'switch.clou_duo_eco_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch][switch.clou_duo_timer-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': 'switch', + 'entity_category': , + 'entity_id': 'switch.clou_duo_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Timer', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Timer', + 'platform': 'fumis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'timer', + 'unique_id': 'aa:bb:cc:dd:ee:ff_timer', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch][switch.clou_duo_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clou Duo Timer', + }), + 'context': , + 'entity_id': 'switch.clou_duo_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fumis/test_switch.py b/tests/components/fumis/test_switch.py new file mode 100644 index 000000000000..dd85f0e57aa5 --- /dev/null +++ b/tests/components/fumis/test_switch.py @@ -0,0 +1,141 @@ +"""Tests for the Fumis switch entities.""" + +from unittest.mock import MagicMock + +from fumis import FumisConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fumis.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .const import UNIQUE_ID + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.parametrize( + "init_integration", [Platform.SWITCH], indirect=True +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Fumis switch entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_eco_mode_turn_on( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test turning on eco mode.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.clou_duo_eco_mode"}, + blocking=True, + ) + + mock_fumis.set_eco_mode.assert_called_once_with(enabled=True) + + +@pytest.mark.usefixtures("init_integration") +async def test_eco_mode_turn_off( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test turning off eco mode.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.clou_duo_eco_mode"}, + blocking=True, + ) + + mock_fumis.set_eco_mode.assert_called_once_with(enabled=False) + + +@pytest.mark.usefixtures("init_integration") +async def test_timer_turn_on( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test turning on the timer.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.clou_duo_timer"}, + blocking=True, + ) + + mock_fumis.set_timer.assert_called_once_with(enabled=True) + + +@pytest.mark.usefixtures("init_integration") +async def test_timer_turn_off( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test turning off the timer.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.clou_duo_timer"}, + blocking=True, + ) + + mock_fumis.set_timer.assert_called_once_with(enabled=False) + + +@pytest.mark.usefixtures("init_integration") +async def test_switch_error_handling( + hass: HomeAssistant, + mock_fumis: MagicMock, +) -> None: + """Test error handling for switch actions.""" + mock_fumis.set_eco_mode.side_effect = FumisConnectionError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.clou_duo_eco_mode"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "communication_error" + + +@pytest.mark.parametrize("device_fixture", ["info_minimal"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_switches_conditional_creation( + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test eco_mode switch is not created when data is missing.""" + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + unique_ids = {entry.unique_id for entry in entity_entries} + + # Eco mode should NOT exist with the minimal fixture + assert f"{UNIQUE_ID}_eco_mode" not in unique_ids + + # Timer should still exist + assert f"{UNIQUE_ID}_timer" in unique_ids