From bb3617ac08bcecf158f7aa52cf2b3e80b9bde284 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:17:22 +0200 Subject: [PATCH] Add switch entitles to Airobot integration (#161090) --- homeassistant/components/airobot/__init__.py | 1 + homeassistant/components/airobot/climate.py | 5 + homeassistant/components/airobot/entity.py | 2 - homeassistant/components/airobot/icons.json | 8 + homeassistant/components/airobot/strings.json | 14 ++ homeassistant/components/airobot/switch.py | 118 ++++++++++++ .../airobot/snapshots/test_switch.ambr | 99 ++++++++++ tests/components/airobot/test_switch.py | 177 ++++++++++++++++++ 8 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/airobot/switch.py create mode 100644 tests/components/airobot/snapshots/test_switch.ambr create mode 100644 tests/components/airobot/test_switch.py diff --git a/homeassistant/components/airobot/__init__.py b/homeassistant/components/airobot/__init__.py index 9cfb819b90d..abd3f5e53b3 100644 --- a/homeassistant/components/airobot/__init__.py +++ b/homeassistant/components/airobot/__init__.py @@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [ Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/airobot/climate.py b/homeassistant/components/airobot/climate.py index 36dc90cf82e..9da653509f9 100644 --- a/homeassistant/components/airobot/climate.py +++ b/homeassistant/components/airobot/climate.py @@ -63,6 +63,11 @@ class AirobotClimate(AirobotEntity, ClimateEntity): _attr_min_temp = SETPOINT_TEMP_MIN _attr_max_temp = SETPOINT_TEMP_MAX + def __init__(self, coordinator) -> None: + """Initialize the climate entity.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.data.status.device_id + @property def _status(self) -> ThermostatStatus: """Get status from coordinator data.""" diff --git a/homeassistant/components/airobot/entity.py b/homeassistant/components/airobot/entity.py index 7a72e42364a..98a00d20c4b 100644 --- a/homeassistant/components/airobot/entity.py +++ b/homeassistant/components/airobot/entity.py @@ -24,8 +24,6 @@ class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]): status = coordinator.data.status settings = coordinator.data.settings - self._attr_unique_id = status.device_id - connections = set() if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None: connections.add((CONNECTION_NETWORK_MAC, mac)) diff --git a/homeassistant/components/airobot/icons.json b/homeassistant/components/airobot/icons.json index 80a8d5b621a..c230efb3707 100644 --- a/homeassistant/components/airobot/icons.json +++ b/homeassistant/components/airobot/icons.json @@ -9,6 +9,14 @@ "hysteresis_band": { "default": "mdi:delta" } + }, + "switch": { + "actuator_exercise_disabled": { + "default": "mdi:valve" + }, + "child_lock": { + "default": "mdi:lock" + } } } } diff --git a/homeassistant/components/airobot/strings.json b/homeassistant/components/airobot/strings.json index 0615da7897b..ecccf553736 100644 --- a/homeassistant/components/airobot/strings.json +++ b/homeassistant/components/airobot/strings.json @@ -85,6 +85,14 @@ "heating_uptime": { "name": "Heating uptime" } + }, + "switch": { + "actuator_exercise_disabled": { + "name": "Actuator exercise disabled" + }, + "child_lock": { + "name": "Child lock" + } } }, "exceptions": { @@ -105,6 +113,12 @@ }, "set_value_failed": { "message": "Failed to set value: {error}" + }, + "switch_turn_off_failed": { + "message": "Failed to turn off {switch}." + }, + "switch_turn_on_failed": { + "message": "Failed to turn on {switch}." } } } diff --git a/homeassistant/components/airobot/switch.py b/homeassistant/components/airobot/switch.py new file mode 100644 index 00000000000..3a7c5d8222d --- /dev/null +++ b/homeassistant/components/airobot/switch.py @@ -0,0 +1,118 @@ +"""Switch platform for Airobot thermostat.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from pyairobotrest.exceptions import AirobotError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AirobotConfigEntry +from .const import DOMAIN +from .coordinator import AirobotDataUpdateCoordinator +from .entity import AirobotEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirobotSwitchEntityDescription(SwitchEntityDescription): + """Describes Airobot switch entity.""" + + is_on_fn: Callable[[AirobotDataUpdateCoordinator], bool] + turn_on_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]] + + +SWITCH_TYPES: tuple[AirobotSwitchEntityDescription, ...] = ( + AirobotSwitchEntityDescription( + key="child_lock", + translation_key="child_lock", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda coordinator: ( + coordinator.data.settings.setting_flags.childlock_enabled + ), + turn_on_fn=lambda coordinator: coordinator.client.set_child_lock(True), + turn_off_fn=lambda coordinator: coordinator.client.set_child_lock(False), + ), + AirobotSwitchEntityDescription( + key="actuator_exercise_disabled", + translation_key="actuator_exercise_disabled", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + is_on_fn=lambda coordinator: ( + coordinator.data.settings.setting_flags.actuator_exercise_disabled + ), + turn_on_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise( + True + ), + turn_off_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise( + False + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirobotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Airobot switch entities.""" + coordinator = entry.runtime_data + + async_add_entities( + AirobotSwitch(coordinator, description) for description in SWITCH_TYPES + ) + + +class AirobotSwitch(AirobotEntity, SwitchEntity): + """Representation of an Airobot switch.""" + + entity_description: AirobotSwitchEntityDescription + + def __init__( + self, + coordinator: AirobotDataUpdateCoordinator, + description: AirobotSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self.entity_description.is_on_fn(self.coordinator) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self.entity_description.turn_on_fn(self.coordinator) + except AirobotError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_turn_on_failed", + translation_placeholders={"switch": self.entity_description.key}, + ) from err + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self.entity_description.turn_off_fn(self.coordinator) + except AirobotError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_turn_off_failed", + translation_placeholders={"switch": self.entity_description.key}, + ) from err + await self.coordinator.async_request_refresh() diff --git a/tests/components/airobot/snapshots/test_switch.ambr b/tests/components/airobot/snapshots/test_switch.ambr new file mode 100644 index 00000000000..6d7d816085c --- /dev/null +++ b/tests/components/airobot/snapshots/test_switch.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_switches[switch.test_thermostat_actuator_exercise_disabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.test_thermostat_actuator_exercise_disabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Actuator exercise disabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Actuator exercise disabled', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'actuator_exercise_disabled', + 'unique_id': 'T01A1B2C3_actuator_exercise_disabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_thermostat_actuator_exercise_disabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Thermostat Actuator exercise disabled', + }), + 'context': , + 'entity_id': 'switch.test_thermostat_actuator_exercise_disabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.test_thermostat_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + '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.test_thermostat_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'T01A1B2C3_child_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.test_thermostat_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Thermostat Child lock', + }), + 'context': , + 'entity_id': 'switch.test_thermostat_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/airobot/test_switch.py b/tests/components/airobot/test_switch.py new file mode 100644 index 00000000000..1cf52202011 --- /dev/null +++ b/tests/components/airobot/test_switch.py @@ -0,0 +1,177 @@ +"""Tests for the Airobot switch platform.""" + +from unittest.mock import AsyncMock + +from pyairobotrest.exceptions import AirobotError +import pytest +from syrupy.assertion import SnapshotAssertion + +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, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_switches( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +@pytest.mark.parametrize( + ("entity_id", "method_name"), + [ + ("switch.test_thermostat_child_lock", "set_child_lock"), + ( + "switch.test_thermostat_actuator_exercise_disabled", + "toggle_actuator_exercise", + ), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, + entity_id: str, + method_name: str, +) -> None: + """Test switch turn on/off functionality.""" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + mock_method = getattr(mock_airobot_client, method_name) + + # Turn on + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_method.assert_called_once_with(True) + mock_method.reset_mock() + + # Turn off + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_method.assert_called_once_with(False) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_switch_state_updates( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, + mock_settings, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that switch state updates when coordinator refreshes.""" + # Initial state - both switches off + child_lock = hass.states.get("switch.test_thermostat_child_lock") + assert child_lock is not None + assert child_lock.state == STATE_OFF + + actuator_disabled = hass.states.get( + "switch.test_thermostat_actuator_exercise_disabled" + ) + assert actuator_disabled is not None + assert actuator_disabled.state == STATE_OFF + + # Update settings to enable both + mock_settings.setting_flags.childlock_enabled = True + mock_settings.setting_flags.actuator_exercise_disabled = True + mock_airobot_client.get_settings.return_value = mock_settings + + # Trigger coordinator update + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + # Verify states updated + child_lock = hass.states.get("switch.test_thermostat_child_lock") + assert child_lock is not None + assert child_lock.state == STATE_ON + + actuator_disabled = hass.states.get( + "switch.test_thermostat_actuator_exercise_disabled" + ) + assert actuator_disabled is not None + assert actuator_disabled.state == STATE_ON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +@pytest.mark.parametrize( + ("entity_id", "method_name", "service", "expected_key"), + [ + ( + "switch.test_thermostat_child_lock", + "set_child_lock", + SERVICE_TURN_ON, + "child_lock", + ), + ( + "switch.test_thermostat_child_lock", + "set_child_lock", + SERVICE_TURN_OFF, + "child_lock", + ), + ( + "switch.test_thermostat_actuator_exercise_disabled", + "toggle_actuator_exercise", + SERVICE_TURN_ON, + "actuator_exercise_disabled", + ), + ( + "switch.test_thermostat_actuator_exercise_disabled", + "toggle_actuator_exercise", + SERVICE_TURN_OFF, + "actuator_exercise_disabled", + ), + ], +) +async def test_switch_error_handling( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, + entity_id: str, + method_name: str, + service: str, + expected_key: str, +) -> None: + """Test switch error handling for turn on/off operations.""" + mock_method = getattr(mock_airobot_client, method_name) + mock_method.side_effect = AirobotError("Test error") + + with pytest.raises(HomeAssistantError, match=expected_key): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + expected_value = service == SERVICE_TURN_ON + mock_method.assert_called_once_with(expected_value)