mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 05:06:13 +01:00
Add switch entitles to Airobot integration (#161090)
This commit is contained in:
@@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"hysteresis_band": {
|
||||
"default": "mdi:delta"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"actuator_exercise_disabled": {
|
||||
"default": "mdi:valve"
|
||||
},
|
||||
"child_lock": {
|
||||
"default": "mdi:lock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
118
homeassistant/components/airobot/switch.py
Normal file
118
homeassistant/components/airobot/switch.py
Normal 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()
|
||||
99
tests/components/airobot/snapshots/test_switch.ambr
Normal file
99
tests/components/airobot/snapshots/test_switch.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
177
tests/components/airobot/test_switch.py
Normal file
177
tests/components/airobot/test_switch.py
Normal 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)
|
||||
Reference in New Issue
Block a user