From 8232415fd5fd2de9e01ea6c5dffa5b8d97e1c676 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Mon, 11 May 2026 16:31:14 +0200 Subject: [PATCH] Add Kiosker switch platform (#168858) --- homeassistant/components/kiosker/__init__.py | 2 +- homeassistant/components/kiosker/const.py | 1 + homeassistant/components/kiosker/icons.json | 5 + homeassistant/components/kiosker/strings.json | 5 + homeassistant/components/kiosker/switch.py | 97 +++++++++ .../kiosker/snapshots/test_switch.ambr | 51 +++++ tests/components/kiosker/test_switch.py | 203 ++++++++++++++++++ 7 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/kiosker/switch.py create mode 100644 tests/components/kiosker/snapshots/test_switch.ambr create mode 100644 tests/components/kiosker/test_switch.py diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index dc1c60746b8c..9059bec9274c 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py index 40cc8b9d0331..d358be495e71 100644 --- a/homeassistant/components/kiosker/const.py +++ b/homeassistant/components/kiosker/const.py @@ -10,3 +10,4 @@ PORT = 8081 POLL_INTERVAL = 15 DEFAULT_SSL = False DEFAULT_SSL_VERIFY = False +REFRESH_DELAY = 0.5 diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index 749a8d7d02e5..f06a809b45f5 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -27,6 +27,11 @@ "last_motion": { "default": "mdi:motion-sensor" } + }, + "switch": { + "disable_screensaver": { + "default": "mdi:power-sleep" + } } } } diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 5cd897381e76..ba8072e29081 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -66,6 +66,11 @@ "last_motion": { "name": "Last motion" } + }, + "switch": { + "disable_screensaver": { + "name": "Disable screensaver" + } } } } diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py new file mode 100644 index 000000000000..1900285725e6 --- /dev/null +++ b/homeassistant/components/kiosker/switch.py @@ -0,0 +1,97 @@ +"""Switch platform for Kiosker.""" + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from kiosker import ( + AuthenticationError, + BadRequestError, + ConnectionError, + IPAuthenticationError, + KioskerAPI, + TLSVerificationError, +) + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import KioskerConfigEntry +from .const import REFRESH_DELAY +from .coordinator import KioskerData +from .entity import KioskerEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class KioskerSwitchEntityDescription(SwitchEntityDescription): + """Kiosker switch description.""" + + set_state_fn: Callable[[KioskerAPI, bool], None] + is_on_fn: Callable[[KioskerData], bool | None] + + +SWITCHES: tuple[KioskerSwitchEntityDescription, ...] = ( + KioskerSwitchEntityDescription( + key="disableScreensaver", + translation_key="disable_screensaver", + set_state_fn=lambda api, disabled: api.screensaver_set_disabled_state(disabled), + is_on_fn=lambda x: x.screensaver.disabled if x.screensaver else None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KioskerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Kiosker switches based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + KioskerSwitch(coordinator, description) for description in SWITCHES + ) + + +class KioskerSwitch(KioskerEntity, SwitchEntity): + """Representation of a Kiosker switch.""" + + entity_description: KioskerSwitchEntityDescription + + @property + def is_on(self) -> bool | None: + """Return true if the switch is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) + + async def _handle_method_call(self, state: bool) -> None: + """Handle method call with error handling.""" + try: + await self.hass.async_add_executor_job( + self.entity_description.set_state_fn, self.coordinator.api, state + ) + except AuthenticationError as exc: + raise HomeAssistantError("Authentication failed") from exc + except IPAuthenticationError as exc: + raise HomeAssistantError("IP Authentication failed") from exc + except ConnectionError as exc: + raise HomeAssistantError(f"Connection failed: {exc}") from exc + except TLSVerificationError as exc: + raise HomeAssistantError(f"TLS verification failed: {exc}") from exc + except BadRequestError as exc: + raise ServiceValidationError(f"Bad request: {exc}") from exc + + await asyncio.sleep(REFRESH_DELAY) + await self.coordinator.async_refresh() + + async def async_turn_on(self, **_kwargs: Any) -> None: + """Turn the switch on.""" + await self._handle_method_call(True) + + async def async_turn_off(self, **_kwargs: Any) -> None: + """Turn the switch off.""" + await self._handle_method_call(False) diff --git a/tests/components/kiosker/snapshots/test_switch.ambr b/tests/components/kiosker/snapshots/test_switch.ambr new file mode 100644 index 000000000000..8371411eeb5d --- /dev/null +++ b/tests/components/kiosker/snapshots/test_switch.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[switch.kiosker_a98be1ce_disable_screensaver-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.kiosker_a98be1ce_disable_screensaver', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Disable screensaver', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disable screensaver', + 'platform': 'kiosker', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disable_screensaver', + 'unique_id': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC_disableScreensaver', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.kiosker_a98be1ce_disable_screensaver-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kiosker A98BE1CE Disable screensaver', + }), + 'context': , + 'entity_id': 'switch.kiosker_a98be1ce_disable_screensaver', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/kiosker/test_switch.py b/tests/components/kiosker/test_switch.py new file mode 100644 index 000000000000..8018106e0c03 --- /dev/null +++ b/tests/components/kiosker/test_switch.py @@ -0,0 +1,203 @@ +"""Test the Kiosker switch platform.""" + +from unittest.mock import MagicMock, patch + +from kiosker import ( + AuthenticationError, + BadRequestError, + Blackout, + ConnectionError, + IPAuthenticationError, + ScreensaverState, + TLSVerificationError, +) +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, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "switch.kiosker_a98be1ce_disable_screensaver" + + +async def _setup_switch( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + *, + screensaver_disabled: bool = False, +) -> None: + mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( + visible=True, disabled=screensaver_disabled + ) + mock_kiosker_api.blackout_get.return_value = Blackout(visible=False) + with patch("homeassistant.components.kiosker._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( + visible=True, disabled=False + ) + mock_kiosker_api.blackout_get.return_value = Blackout(visible=False) + + with patch("homeassistant.components.kiosker._PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_turn_on( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning the screensaver disable switch on.""" + await _setup_switch( + hass, mock_kiosker_api, mock_config_entry, screensaver_disabled=False + ) + + mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( + visible=True, disabled=True + ) + with patch("homeassistant.components.kiosker.switch.REFRESH_DELAY", 0): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_kiosker_api.screensaver_set_disabled_state.assert_called_once_with(True) + assert hass.states.get(ENTITY_ID).state == "on" + + +async def test_turn_off( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning the screensaver disable switch off.""" + await _setup_switch( + hass, mock_kiosker_api, mock_config_entry, screensaver_disabled=True + ) + + mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( + visible=True, disabled=False + ) + with patch("homeassistant.components.kiosker.switch.REFRESH_DELAY", 0): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_kiosker_api.screensaver_set_disabled_state.assert_called_once_with(False) + assert hass.states.get(ENTITY_ID).state == "off" + + +async def test_state_reflects_coordinator_data( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that state reflects coordinator data with no optimistic override.""" + await _setup_switch( + hass, mock_kiosker_api, mock_config_entry, screensaver_disabled=False + ) + + # API still reports disabled=False (device hasn't updated yet) + with patch("homeassistant.components.kiosker.switch.REFRESH_DELAY", 0): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + # Coordinator is the sole source of truth — reports off since device hasn't updated + assert hass.states.get(ENTITY_ID).state == "off" + + +@pytest.mark.parametrize( + ("exception", "expected_exception"), + [ + (AuthenticationError, HomeAssistantError), + (IPAuthenticationError, HomeAssistantError), + (ConnectionError, HomeAssistantError), + (TLSVerificationError, HomeAssistantError), + (BadRequestError, ServiceValidationError), + ], +) +async def test_turn_on_errors( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + exception: type[Exception], + expected_exception: type[Exception], +) -> None: + """Test that API errors on turn_on are mapped to HA exceptions.""" + await _setup_switch(hass, mock_kiosker_api, mock_config_entry) + + mock_kiosker_api.screensaver_set_disabled_state.side_effect = exception("error") + + with pytest.raises(expected_exception): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("exception", "expected_exception"), + [ + (AuthenticationError, HomeAssistantError), + (IPAuthenticationError, HomeAssistantError), + (ConnectionError, HomeAssistantError), + (TLSVerificationError, HomeAssistantError), + (BadRequestError, ServiceValidationError), + ], +) +async def test_turn_off_errors( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + exception: type[Exception], + expected_exception: type[Exception], +) -> None: + """Test that API errors on turn_off are mapped to HA exceptions.""" + await _setup_switch( + hass, mock_kiosker_api, mock_config_entry, screensaver_disabled=True + ) + + mock_kiosker_api.screensaver_set_disabled_state.side_effect = exception("error") + + with pytest.raises(expected_exception): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + )