Add switch platform to Fumis integration (#169096)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: frenck <195327+frenck@users.noreply.github.com>
This commit is contained in:
Franck Nijhof
2026-04-29 19:50:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.6 copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> frenck
parent dba17323a7
commit 3725e498ff
6 changed files with 359 additions and 0 deletions
@@ -13,6 +13,7 @@ PLATFORMS = [
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -99,6 +99,14 @@
"wifi_signal_strength": {
"default": "mdi:wifi"
}
},
"switch": {
"eco_mode": {
"default": "mdi:leaf"
},
"timer": {
"default": "mdi:timer"
}
}
}
}
@@ -188,6 +188,14 @@
"wifi_signal_strength": {
"name": "Wi-Fi signal strength"
}
},
"switch": {
"eco_mode": {
"name": "Eco mode"
},
"timer": {
"name": "Timer"
}
}
},
"exceptions": {
+100
View File
@@ -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()
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.clou_duo_eco_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.clou_duo_eco_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switches[switch][switch.clou_duo_timer-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': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.clou_duo_timer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.clou_duo_timer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+141
View File
@@ -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