From 81be14c8f1caa9a9c85a00428aada7b378d9e35a Mon Sep 17 00:00:00 2001 From: Kurt Chrisford <92524101+kclif9@users.noreply.github.com> Date: Fri, 19 Dec 2025 04:11:39 +1000 Subject: [PATCH] Actron Air: Add switch entity platform (#158087) Co-authored-by: Joostlek --- .../components/actron_air/__init__.py | 6 +- .../components/actron_air/icons.json | 30 +++ .../components/actron_air/strings.json | 16 ++ homeassistant/components/actron_air/switch.py | 110 ++++++++++ tests/components/actron_air/__init__.py | 12 ++ tests/components/actron_air/conftest.py | 48 ++++- .../actron_air/snapshots/test_switch.ambr | 193 ++++++++++++++++++ .../components/actron_air/test_config_flow.py | 9 +- tests/components/actron_air/test_switch.py | 90 ++++++++ 9 files changed, 504 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/actron_air/icons.json create mode 100644 homeassistant/components/actron_air/switch.py create mode 100644 tests/components/actron_air/snapshots/test_switch.ambr create mode 100644 tests/components/actron_air/test_switch.py diff --git a/homeassistant/components/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py index a05aeca1e29..7048e76512f 100644 --- a/homeassistant/components/actron_air/__init__.py +++ b/homeassistant/components/actron_air/__init__.py @@ -18,7 +18,7 @@ from .coordinator import ( ActronAirSystemCoordinator, ) -PLATFORM = [Platform.CLIMATE] +PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: @@ -50,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> system_coordinators=system_coordinators, ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORM) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORM) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/actron_air/icons.json b/homeassistant/components/actron_air/icons.json new file mode 100644 index 00000000000..0716c845104 --- /dev/null +++ b/homeassistant/components/actron_air/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "switch": { + "away_mode": { + "default": "mdi:home-export-outline", + "state": { + "off": "mdi:home-import-outline" + } + }, + "continuous_fan": { + "default": "mdi:fan", + "state": { + "off": "mdi:fan-off" + } + }, + "quiet_mode": { + "default": "mdi:volume-low", + "state": { + "off": "mdi:volume-high" + } + }, + "turbo_mode": { + "default": "mdi:fan-plus", + "state": { + "off": "mdi:fan" + } + } + } + } +} diff --git a/homeassistant/components/actron_air/strings.json b/homeassistant/components/actron_air/strings.json index a611a89201b..b7a94efad0a 100644 --- a/homeassistant/components/actron_air/strings.json +++ b/homeassistant/components/actron_air/strings.json @@ -32,6 +32,22 @@ } } }, + "entity": { + "switch": { + "away_mode": { + "name": "Away mode" + }, + "continuous_fan": { + "name": "Continuous fan" + }, + "quiet_mode": { + "name": "Quiet mode" + }, + "turbo_mode": { + "name": "Turbo mode" + } + } + }, "exceptions": { "auth_error": { "message": "Authentication failed, please reauthenticate" diff --git a/homeassistant/components/actron_air/switch.py b/homeassistant/components/actron_air/switch.py new file mode 100644 index 00000000000..b886d82e5f9 --- /dev/null +++ b/homeassistant/components/actron_air/switch.py @@ -0,0 +1,110 @@ +"""Switch platform for Actron Air integration.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class ActronAirSwitchEntityDescription(SwitchEntityDescription): + """Class describing Actron Air switch entities.""" + + is_on_fn: Callable[[ActronAirSystemCoordinator], bool] + set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]] + is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True + + +SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = ( + ActronAirSwitchEntityDescription( + key="away_mode", + translation_key="away_mode", + is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode, + set_fn=lambda coordinator, + enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled), + ), + ActronAirSwitchEntityDescription( + key="continuous_fan", + translation_key="continuous_fan", + is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled, + set_fn=lambda coordinator, + enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled), + ), + ActronAirSwitchEntityDescription( + key="quiet_mode", + translation_key="quiet_mode", + is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled, + set_fn=lambda coordinator, + enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled), + ), + ActronAirSwitchEntityDescription( + key="turbo_mode", + translation_key="turbo_mode", + is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled, + set_fn=lambda coordinator, + enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled), + is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ActronAirConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Actron Air switch entities.""" + system_coordinators = entry.runtime_data.system_coordinators + async_add_entities( + ActronAirSwitch(coordinator, description) + for coordinator in system_coordinators.values() + for description in SWITCHES + if description.is_supported_fn(coordinator) + ) + + +class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntity): + """Actron Air switch.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + entity_description: ActronAirSwitchEntityDescription + + def __init__( + self, + coordinator: ActronAirSystemCoordinator, + description: ActronAirSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.serial_number)}, + manufacturer="Actron Air", + name=coordinator.data.ac_system.system_name, + ) + + @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.""" + await self.entity_description.set_fn(self.coordinator, True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.set_fn(self.coordinator, False) diff --git a/tests/components/actron_air/__init__.py b/tests/components/actron_air/__init__.py index c2f40057ab7..235476db98c 100644 --- a/tests/components/actron_air/__init__.py +++ b/tests/components/actron_air/__init__.py @@ -1 +1,13 @@ """Tests for the Actron Air integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/actron_air/conftest.py b/tests/components/actron_air/conftest.py index 6b8a189458d..6f1b4869882 100644 --- a/tests/components/actron_air/conftest.py +++ b/tests/components/actron_air/conftest.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -53,9 +53,40 @@ def mock_actron_api() -> Generator[AsyncMock]: # Mock refresh token property api.refresh_token_value = "test_refresh_token" - # Mock other API methods that might be used - api.get_systems = AsyncMock(return_value=[]) - api.get_status = AsyncMock(return_value=None) + # Mock get_ac_systems + api.get_ac_systems = AsyncMock( + return_value=[{"serial": "123456", "name": "Test System"}] + ) + + # Mock state manager + api.state_manager = MagicMock() + status = api.state_manager.get_status.return_value + status.master_info.live_temp_c = 22.0 + status.ac_system.system_name = "Test System" + status.ac_system.serial_number = "123456" + status.ac_system.master_wc_model = "Test Model" + status.ac_system.master_wc_firmware_version = "1.0.0" + status.remote_zone_info = [] + status.min_temp = 16 + status.max_temp = 30 + status.aircon_system.mode = "OFF" + status.fan_mode = "LOW" + status.set_point = 24 + status.room_temp = 25 + status.is_on = False + + # Mock user_aircon_settings for the switch platform + settings = status.user_aircon_settings + settings.away_mode = False + settings.continuous_fan_enabled = False + settings.quiet_mode_enabled = False + settings.turbo_enabled = False + settings.turbo_supported = True + + settings.set_away_mode = AsyncMock() + settings.set_continuous_mode = AsyncMock() + settings.set_quiet_mode = AsyncMock() + settings.set_turbo_mode = AsyncMock() yield api @@ -69,3 +100,12 @@ def mock_config_entry() -> MockConfigEntry: data={CONF_API_TOKEN: "test_refresh_token"}, unique_id="test_user_id", ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.actron_air.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/actron_air/snapshots/test_switch.ambr b/tests/components/actron_air/snapshots/test_switch.ambr new file mode 100644 index 00000000000..5735835c8ea --- /dev/null +++ b/tests/components/actron_air/snapshots/test_switch.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_switch_entities[switch.test_system_away_mode-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_system_away_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away mode', + 'platform': 'actron_air', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'away_mode', + 'unique_id': '123456_away_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_system_away_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test System Away mode', + }), + 'context': , + 'entity_id': 'switch.test_system_away_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[switch.test_system_continuous_fan-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_system_continuous_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Continuous fan', + 'platform': 'actron_air', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'continuous_fan', + 'unique_id': '123456_continuous_fan', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_system_continuous_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test System Continuous fan', + }), + 'context': , + 'entity_id': 'switch.test_system_continuous_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[switch.test_system_quiet_mode-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_system_quiet_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Quiet mode', + 'platform': 'actron_air', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quiet_mode', + 'unique_id': '123456_quiet_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_system_quiet_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test System Quiet mode', + }), + 'context': , + 'entity_id': 'switch.test_system_quiet_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_entities[switch.test_system_turbo_mode-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_system_turbo_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Turbo mode', + 'platform': 'actron_air', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turbo_mode', + 'unique_id': '123456_turbo_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.test_system_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test System Turbo mode', + }), + 'context': , + 'entity_id': 'switch.test_system_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/actron_air/test_config_flow.py b/tests/components/actron_air/test_config_flow.py index bbe7dcbca1e..113af461c89 100644 --- a/tests/components/actron_air/test_config_flow.py +++ b/tests/components/actron_air/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def test_user_flow_oauth2_success( - hass: HomeAssistant, mock_actron_api: AsyncMock + hass: HomeAssistant, mock_actron_api: AsyncMock, mock_setup_entry: AsyncMock ) -> None: """Test successful OAuth2 device code flow.""" # Start the config flow @@ -90,7 +90,7 @@ async def test_user_flow_oauth2_error(hass: HomeAssistant, mock_actron_api) -> N async def test_user_flow_token_polling_error( - hass: HomeAssistant, mock_actron_api + hass: HomeAssistant, mock_actron_api, mock_setup_entry: AsyncMock ) -> None: """Test OAuth2 flow with error during token polling.""" # Override the default mock to raise an error during token polling @@ -179,7 +179,10 @@ async def test_user_flow_duplicate_account( async def test_reauth_flow_success( - hass: HomeAssistant, mock_actron_api: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_actron_api: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, ) -> None: """Test successful reauthentication flow.""" # Create an existing config entry diff --git a/tests/components/actron_air/test_switch.py b/tests/components/actron_air/test_switch.py new file mode 100644 index 00000000000..2464ae8d0a0 --- /dev/null +++ b/tests/components/actron_air/test_switch.py @@ -0,0 +1,90 @@ +"""Tests for the Actron Air switch platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switch_entities( + hass: HomeAssistant, + mock_actron_api: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch entities.""" + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("switch.test_system_away_mode", "set_away_mode"), + ("switch.test_system_continuous_fan", "set_continuous_mode"), + ("switch.test_system_quiet_mode", "set_quiet_mode"), + ("switch.test_system_turbo_mode", "set_turbo_mode"), + ], +) +async def test_switch_toggles( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, +) -> None: + """Test switch toggles.""" + with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + status = mock_actron_api.state_manager.get_status.return_value + mock_method = getattr(status.user_aircon_settings, method) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_method.assert_awaited_once_with(True) + mock_method.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_method.assert_awaited_once_with(False) + + +async def test_turbo_mode_not_supported( + hass: HomeAssistant, + mock_actron_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test turbo mode switch is not created when not supported.""" + status = mock_actron_api.state_manager.get_status.return_value + status.user_aircon_settings.turbo_supported = False + + await setup_integration(hass, mock_config_entry) + + entity_id = "switch.test_system_turbo_mode" + assert not hass.states.get(entity_id) + assert not entity_registry.async_get(entity_id)