From 145cdbaf77674c7d8b58b4ab48ff2a3d538e7d7a Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 22 Apr 2026 21:58:12 +0000 Subject: [PATCH 01/11] Add switch platform --- homeassistant/components/kiosker/__init__.py | 2 +- homeassistant/components/kiosker/icons.json | 5 + homeassistant/components/kiosker/strings.json | 5 + homeassistant/components/kiosker/switch.py | 124 ++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/kiosker/switch.py diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 21ba2bc5f2ef33..77c617b5f37c69 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -7,7 +7,7 @@ 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/icons.json b/homeassistant/components/kiosker/icons.json index 749a8d7d02e596..f06a809b45f546 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 5cd897381e76f8..ba8072e2908158 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 00000000000000..b170de1268679f --- /dev/null +++ b/homeassistant/components/kiosker/switch.py @@ -0,0 +1,124 @@ +"""Switch platform for Kiosker.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from kiosker import ( + AuthenticationError, + BadRequestError, + ConnectionError, + IPAuthenticationError, + 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 .coordinator import KioskerData, KioskerDataUpdateCoordinator +from .entity import KioskerEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class KioskerSwitchEntityDescription(SwitchEntityDescription): + """Kiosker switch description.""" + + method: str + is_on_fn: Callable[[KioskerData], bool | None] + + +SWITCHES: tuple[KioskerSwitchEntityDescription, ...] = ( + KioskerSwitchEntityDescription( + key="disableScreensaver", + translation_key="disable_screensaver", + method="async_set_screensaver_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 + + def __init__( + self, + coordinator: KioskerDataUpdateCoordinator, + description: KioskerSwitchEntityDescription, + ) -> None: + """Initialize the switch entity.""" + super().__init__(coordinator, description) + self._method = getattr(self, description.method) + self._optimistic_state: bool | None = None + + @property + def is_on(self) -> bool | None: + """Return true if the switch is on.""" + # Use optimistic state if available (during API calls) + if self._optimistic_state is not None: + return self._optimistic_state + + return self.entity_description.is_on_fn(self.coordinator.data) + + async def _handle_method_call(self, state: bool, action: str) -> None: + """Handle method call with error handling and state management.""" + try: + await self._method(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 + except Exception as exc: + _LOGGER.exception("Unexpected error %s switch", action) + raise HomeAssistantError(f"Unexpected error: {exc}") from exc + + # Set optimistic state + self._optimistic_state = state + self.async_write_ha_state() + + async def async_turn_on(self, **_kwargs: Any) -> None: + """Turn the switch on.""" + await self._handle_method_call(True, "turning on") + + async def async_turn_off(self, **_kwargs: Any) -> None: + """Turn the switch off.""" + await self._handle_method_call(False, "turning off") + + async def async_set_screensaver_disabled(self, disabled: bool) -> None: + """Set screensaver disabled state.""" + await self.hass.async_add_executor_job( + self.coordinator.api.screensaver_set_disabled_state, disabled + ) + await self.coordinator.async_request_refresh() + # Clear optimistic state after refresh + self._optimistic_state = None From 7e739e0771bf8dae1bbe95c564051f3e6a794098 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 22 Apr 2026 22:00:59 +0000 Subject: [PATCH 02/11] Test for switch --- .../kiosker/snapshots/test_switch.ambr | 51 +++++++++++++++++++ tests/components/kiosker/test_switch.py | 35 +++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/components/kiosker/snapshots/test_switch.ambr create mode 100644 tests/components/kiosker/test_switch.py diff --git a/tests/components/kiosker/snapshots/test_switch.ambr b/tests/components/kiosker/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..8371411eeb5d6c --- /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 00000000000000..c07b6a57593a8e --- /dev/null +++ b/tests/components/kiosker/test_switch.py @@ -0,0 +1,35 @@ +"""Test the Kiosker switch platform.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from kiosker import Blackout, ScreensaverState +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import 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_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) From 2426a6ae5e6fa57023c3c00d0dc346443e1b09a2 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 23 Apr 2026 20:16:39 +0000 Subject: [PATCH 03/11] Fixed p --- homeassistant/components/kiosker/switch.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index b170de1268679f..0b64290931bd90 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -102,9 +102,13 @@ async def _handle_method_call(self, state: bool, action: str) -> None: _LOGGER.exception("Unexpected error %s switch", action) raise HomeAssistantError(f"Unexpected error: {exc}") from exc - # Set optimistic state + # Refresh coordinator data to get updated device state + await self.coordinator.async_request_refresh() + + # Set optimistic state briefly for UI feedback, then clear after refresh self._optimistic_state = state self.async_write_ha_state() + self._optimistic_state = None async def async_turn_on(self, **_kwargs: Any) -> None: """Turn the switch on.""" @@ -119,6 +123,3 @@ async def async_set_screensaver_disabled(self, disabled: bool) -> None: await self.hass.async_add_executor_job( self.coordinator.api.screensaver_set_disabled_state, disabled ) - await self.coordinator.async_request_refresh() - # Clear optimistic state after refresh - self._optimistic_state = None From 49f7e3c7f28956f79c794376870b231d9b885396 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 23 Apr 2026 20:39:46 +0000 Subject: [PATCH 04/11] Fixed optimistic state --- homeassistant/components/kiosker/switch.py | 24 +++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index 0b64290931bd90..6645af08ec427f 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -16,7 +16,7 @@ ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -73,14 +73,14 @@ def __init__( """Initialize the switch entity.""" super().__init__(coordinator, description) self._method = getattr(self, description.method) - self._optimistic_state: bool | None = None + self._control_result: bool | None = None @property def is_on(self) -> bool | None: """Return true if the switch is on.""" # Use optimistic state if available (during API calls) - if self._optimistic_state is not None: - return self._optimistic_state + if self._control_result is not None: + return self._control_result return self.entity_description.is_on_fn(self.coordinator.data) @@ -102,13 +102,9 @@ async def _handle_method_call(self, state: bool, action: str) -> None: _LOGGER.exception("Unexpected error %s switch", action) raise HomeAssistantError(f"Unexpected error: {exc}") from exc - # Refresh coordinator data to get updated device state - await self.coordinator.async_request_refresh() - - # Set optimistic state briefly for UI feedback, then clear after refresh - self._optimistic_state = state + # Set optimistic state for immediate UI feedback + self._control_result = state self.async_write_ha_state() - self._optimistic_state = None async def async_turn_on(self, **_kwargs: Any) -> None: """Turn the switch on.""" @@ -123,3 +119,11 @@ async def async_set_screensaver_disabled(self, disabled: bool) -> None: await self.hass.async_add_executor_job( self.coordinator.api.screensaver_set_disabled_state, disabled ) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update.""" + # Clear optimistic state when real data arrives + self._control_result = None + super()._handle_coordinator_update() From 2bffba24589f4383e73b0cc4835c149b97c1a775 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 23 Apr 2026 20:57:37 +0000 Subject: [PATCH 05/11] Added code coverage --- tests/components/kiosker/test_switch.py | 177 +++++++++++++++++++++++- 1 file changed, 175 insertions(+), 2 deletions(-) diff --git a/tests/components/kiosker/test_switch.py b/tests/components/kiosker/test_switch.py index c07b6a57593a8e..2739ba18c5fc2e 100644 --- a/tests/components/kiosker/test_switch.py +++ b/tests/components/kiosker/test_switch.py @@ -4,17 +4,50 @@ from unittest.mock import MagicMock, patch -from kiosker import Blackout, ScreensaverState +from kiosker import ( + AuthenticationError, + BadRequestError, + Blackout, + ConnectionError, + IPAuthenticationError, + ScreensaverState, + TLSVerificationError, +) +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +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, @@ -33,3 +66,143 @@ async def test_all_entities( 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 + ) + + 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 + ) + + 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_optimistic_state_cleared_on_coordinator_update( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that optimistic state is cleared when the coordinator updates.""" + await _setup_switch( + hass, mock_kiosker_api, mock_config_entry, screensaver_disabled=False + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + # Optimistic state: on + assert hass.states.get(ENTITY_ID).state == "on" + + # Simulate a coordinator poll where the API still reports disabled=False + mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( + visible=True, disabled=False + ) + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + # Optimistic state cleared; real data shows off + assert hass.states.get(ENTITY_ID).state == "off" + + +@pytest.mark.parametrize( + ("exception", "expected_exception"), + [ + (AuthenticationError, HomeAssistantError), + (IPAuthenticationError, HomeAssistantError), + (ConnectionError, HomeAssistantError), + (TLSVerificationError, HomeAssistantError), + (BadRequestError, ServiceValidationError), + (RuntimeError, HomeAssistantError), + ], +) +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), + (RuntimeError, HomeAssistantError), + ], +) +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, + ) From 955f3e723a9a63980100a7ba36a120c0170b70d7 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 23 Apr 2026 21:38:56 +0000 Subject: [PATCH 06/11] added state lamba --- homeassistant/components/kiosker/switch.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index 6645af08ec427f..6ad6fc566465cb 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -12,6 +12,7 @@ BadRequestError, ConnectionError, IPAuthenticationError, + KioskerAPI, TLSVerificationError, ) @@ -33,7 +34,7 @@ class KioskerSwitchEntityDescription(SwitchEntityDescription): """Kiosker switch description.""" - method: str + set_state_fn: Callable[[KioskerAPI, bool], None] is_on_fn: Callable[[KioskerData], bool | None] @@ -41,7 +42,7 @@ class KioskerSwitchEntityDescription(SwitchEntityDescription): KioskerSwitchEntityDescription( key="disableScreensaver", translation_key="disable_screensaver", - method="async_set_screensaver_disabled", + 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, ), ) @@ -72,13 +73,11 @@ def __init__( ) -> None: """Initialize the switch entity.""" super().__init__(coordinator, description) - self._method = getattr(self, description.method) self._control_result: bool | None = None @property def is_on(self) -> bool | None: """Return true if the switch is on.""" - # Use optimistic state if available (during API calls) if self._control_result is not None: return self._control_result @@ -87,7 +86,10 @@ def is_on(self) -> bool | None: async def _handle_method_call(self, state: bool, action: str) -> None: """Handle method call with error handling and state management.""" try: - await self._method(state) + await self.hass.async_add_executor_job( + self.entity_description.set_state_fn, self.coordinator.api, state + ) + await self.coordinator.async_request_refresh() except AuthenticationError as exc: raise HomeAssistantError("Authentication failed") from exc except IPAuthenticationError as exc: @@ -102,7 +104,6 @@ async def _handle_method_call(self, state: bool, action: str) -> None: _LOGGER.exception("Unexpected error %s switch", action) raise HomeAssistantError(f"Unexpected error: {exc}") from exc - # Set optimistic state for immediate UI feedback self._control_result = state self.async_write_ha_state() @@ -114,16 +115,8 @@ async def async_turn_off(self, **_kwargs: Any) -> None: """Turn the switch off.""" await self._handle_method_call(False, "turning off") - async def async_set_screensaver_disabled(self, disabled: bool) -> None: - """Set screensaver disabled state.""" - await self.hass.async_add_executor_job( - self.coordinator.api.screensaver_set_disabled_state, disabled - ) - await self.coordinator.async_request_refresh() - @callback def _handle_coordinator_update(self) -> None: """Handle coordinator update.""" - # Clear optimistic state when real data arrives self._control_result = None super()._handle_coordinator_update() From 5839e763a7bc3a06a1b39ebe582a37bc955f804a Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 11 May 2026 12:47:49 +0000 Subject: [PATCH 07/11] Removed optimistic state and fixed tests --- homeassistant/components/kiosker/switch.py | 39 ++++---------- tests/components/kiosker/test_switch.py | 61 ++++++++++------------ 2 files changed, 38 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index 6ad6fc566465cb..5179c49f8691a8 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from dataclasses import dataclass import logging @@ -17,12 +18,12 @@ ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KioskerConfigEntry -from .coordinator import KioskerData, KioskerDataUpdateCoordinator +from .coordinator import KioskerData from .entity import KioskerEntity _LOGGER = logging.getLogger(__name__) @@ -66,30 +67,17 @@ class KioskerSwitch(KioskerEntity, SwitchEntity): entity_description: KioskerSwitchEntityDescription - def __init__( - self, - coordinator: KioskerDataUpdateCoordinator, - description: KioskerSwitchEntityDescription, - ) -> None: - """Initialize the switch entity.""" - super().__init__(coordinator, description) - self._control_result: bool | None = None - @property def is_on(self) -> bool | None: """Return true if the switch is on.""" - if self._control_result is not None: - return self._control_result - return self.entity_description.is_on_fn(self.coordinator.data) - async def _handle_method_call(self, state: bool, action: str) -> None: - """Handle method call with error handling and state management.""" + 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 ) - await self.coordinator.async_request_refresh() except AuthenticationError as exc: raise HomeAssistantError("Authentication failed") from exc except IPAuthenticationError as exc: @@ -100,23 +88,14 @@ async def _handle_method_call(self, state: bool, action: str) -> None: raise HomeAssistantError(f"TLS verification failed: {exc}") from exc except BadRequestError as exc: raise ServiceValidationError(f"Bad request: {exc}") from exc - except Exception as exc: - _LOGGER.exception("Unexpected error %s switch", action) - raise HomeAssistantError(f"Unexpected error: {exc}") from exc - self._control_result = state - self.async_write_ha_state() + await asyncio.sleep(0.5) + await self.coordinator.async_refresh() async def async_turn_on(self, **_kwargs: Any) -> None: """Turn the switch on.""" - await self._handle_method_call(True, "turning 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, "turning off") - - @callback - def _handle_coordinator_update(self) -> None: - """Handle coordinator update.""" - self._control_result = None - super()._handle_coordinator_update() + await self._handle_method_call(False) diff --git a/tests/components/kiosker/test_switch.py b/tests/components/kiosker/test_switch.py index 2739ba18c5fc2e..71c0965fc98117 100644 --- a/tests/components/kiosker/test_switch.py +++ b/tests/components/kiosker/test_switch.py @@ -78,12 +78,16 @@ async def test_turn_on( hass, mock_kiosker_api, mock_config_entry, screensaver_disabled=False ) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID}, - blocking=True, + mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( + visible=True, disabled=True ) + with patch("homeassistant.components.kiosker.switch.asyncio.sleep"): + 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" @@ -99,46 +103,41 @@ async def test_turn_off( hass, mock_kiosker_api, mock_config_entry, screensaver_disabled=True ) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_ID}, - blocking=True, + mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( + visible=True, disabled=False ) + with patch("homeassistant.components.kiosker.switch.asyncio.sleep"): + 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_optimistic_state_cleared_on_coordinator_update( +async def test_state_reflects_coordinator_data( hass: HomeAssistant, mock_kiosker_api: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that optimistic state is cleared when the coordinator updates.""" + """Test that state reflects coordinator data with no optimistic override.""" await _setup_switch( hass, mock_kiosker_api, mock_config_entry, screensaver_disabled=False ) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID}, - blocking=True, - ) - - # Optimistic state: on - assert hass.states.get(ENTITY_ID).state == "on" - - # Simulate a coordinator poll where the API still reports disabled=False - mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( - visible=True, disabled=False - ) - coordinator = mock_config_entry.runtime_data - await coordinator.async_refresh() - await hass.async_block_till_done() + # API still reports disabled=False (device hasn't updated yet) + with patch("homeassistant.components.kiosker.switch.asyncio.sleep"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) - # Optimistic state cleared; real data shows off + # Coordinator is the sole source of truth — reports off since device hasn't updated assert hass.states.get(ENTITY_ID).state == "off" @@ -150,7 +149,6 @@ async def test_optimistic_state_cleared_on_coordinator_update( (ConnectionError, HomeAssistantError), (TLSVerificationError, HomeAssistantError), (BadRequestError, ServiceValidationError), - (RuntimeError, HomeAssistantError), ], ) async def test_turn_on_errors( @@ -182,7 +180,6 @@ async def test_turn_on_errors( (ConnectionError, HomeAssistantError), (TLSVerificationError, HomeAssistantError), (BadRequestError, ServiceValidationError), - (RuntimeError, HomeAssistantError), ], ) async def test_turn_off_errors( From e5dcdb0986cb92bd86b7baeb0dd641f9bd500370 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 11 May 2026 12:55:45 +0000 Subject: [PATCH 08/11] Removed unused logging --- homeassistant/components/kiosker/switch.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index 5179c49f8691a8..ce78ac666f895c 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -import logging from typing import Any from kiosker import ( @@ -26,8 +25,6 @@ from .coordinator import KioskerData from .entity import KioskerEntity -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 0 From c61a5c971a974e355db801ad89ad74223465b79a Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 11 May 2026 13:07:07 +0000 Subject: [PATCH 09/11] Removed future annotations --- tests/components/kiosker/test_switch.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/kiosker/test_switch.py b/tests/components/kiosker/test_switch.py index 71c0965fc98117..f4d8c094a455e7 100644 --- a/tests/components/kiosker/test_switch.py +++ b/tests/components/kiosker/test_switch.py @@ -1,7 +1,5 @@ """Test the Kiosker switch platform.""" -from __future__ import annotations - from unittest.mock import MagicMock, patch from kiosker import ( From acc0f6c3ba404926cfe22f99f1020c36624ba240 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 11 May 2026 13:27:16 +0000 Subject: [PATCH 10/11] Moved delay time to const.py --- homeassistant/components/kiosker/const.py | 1 + homeassistant/components/kiosker/switch.py | 3 ++- tests/components/kiosker/test_switch.py | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py index 40cc8b9d03310d..d358be495e7161 100644 --- a/homeassistant/components/kiosker/const.py +++ b/homeassistant/components/kiosker/const.py @@ -10,3 +10,4 @@ POLL_INTERVAL = 15 DEFAULT_SSL = False DEFAULT_SSL_VERIFY = False +REFRESH_DELAY = 0.5 diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index ce78ac666f895c..d229620ebbda6f 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -22,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KioskerConfigEntry +from .const import REFRESH_DELAY from .coordinator import KioskerData from .entity import KioskerEntity @@ -86,7 +87,7 @@ async def _handle_method_call(self, state: bool) -> None: except BadRequestError as exc: raise ServiceValidationError(f"Bad request: {exc}") from exc - await asyncio.sleep(0.5) + await asyncio.sleep(REFRESH_DELAY) await self.coordinator.async_refresh() async def async_turn_on(self, **_kwargs: Any) -> None: diff --git a/tests/components/kiosker/test_switch.py b/tests/components/kiosker/test_switch.py index f4d8c094a455e7..8018106e0c038d 100644 --- a/tests/components/kiosker/test_switch.py +++ b/tests/components/kiosker/test_switch.py @@ -79,7 +79,7 @@ async def test_turn_on( mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( visible=True, disabled=True ) - with patch("homeassistant.components.kiosker.switch.asyncio.sleep"): + with patch("homeassistant.components.kiosker.switch.REFRESH_DELAY", 0): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -104,7 +104,7 @@ async def test_turn_off( mock_kiosker_api.screensaver_get_state.return_value = ScreensaverState( visible=True, disabled=False ) - with patch("homeassistant.components.kiosker.switch.asyncio.sleep"): + with patch("homeassistant.components.kiosker.switch.REFRESH_DELAY", 0): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -127,7 +127,7 @@ async def test_state_reflects_coordinator_data( ) # API still reports disabled=False (device hasn't updated yet) - with patch("homeassistant.components.kiosker.switch.asyncio.sleep"): + with patch("homeassistant.components.kiosker.switch.REFRESH_DELAY", 0): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, From f835dc73639ff4f02658a441f85cb4a158e436ef Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 11 May 2026 13:31:37 +0000 Subject: [PATCH 11/11] Rmeoved feature annotations --- homeassistant/components/kiosker/switch.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index d229620ebbda6f..1900285725e69c 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -1,7 +1,5 @@ """Switch platform for Kiosker.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass