Add switch entitles to Airobot integration (#161090)

This commit is contained in:
mettolen
2026-01-17 14:17:22 +02:00
committed by GitHub
parent 48d1bd13fa
commit bb3617ac08
8 changed files with 422 additions and 2 deletions

View File

@@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -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."""

View File

@@ -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))

View File

@@ -9,6 +9,14 @@
"hysteresis_band": {
"default": "mdi:delta"
}
},
"switch": {
"actuator_exercise_disabled": {
"default": "mdi:valve"
},
"child_lock": {
"default": "mdi:lock"
}
}
}
}

View File

@@ -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}."
}
}
}

View File

@@ -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()

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.test_thermostat_actuator_exercise_disabled',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_thermostat_actuator_exercise_disabled',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switches[switch.test_thermostat_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'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.test_thermostat_child_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_thermostat_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -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)