mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add Kiosker switch platform (#168858)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -10,3 +10,4 @@ PORT = 8081
|
||||
POLL_INTERVAL = 15
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_SSL_VERIFY = False
|
||||
REFRESH_DELAY = 0.5
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
"last_motion": {
|
||||
"default": "mdi:motion-sensor"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"disable_screensaver": {
|
||||
"default": "mdi:power-sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@
|
||||
"last_motion": {
|
||||
"name": "Last motion"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"disable_screensaver": {
|
||||
"name": "Disable screensaver"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'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': <ANY>,
|
||||
'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': <ANY>,
|
||||
'entity_id': 'switch.kiosker_a98be1ce_disable_screensaver',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user