From 65542a156fada346a391a28a25ef920075f4db4a Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 19 Jun 2025 21:50:51 +0200 Subject: [PATCH 01/69] Kiosker --- CODEOWNERS | 1 + homeassistant/components/kiosker/Claude.md | 55 +++++ homeassistant/components/kiosker/__init__.py | 206 ++++++++++++++++ .../components/kiosker/config_flow.py | 220 ++++++++++++++++++ homeassistant/components/kiosker/const.py | 42 ++++ .../components/kiosker/coordinator.py | 51 ++++ homeassistant/components/kiosker/entity.py | 87 +++++++ homeassistant/components/kiosker/icons.json | 37 +++ .../components/kiosker/manifest.json | 14 ++ .../components/kiosker/quality_scale.yaml | 60 +++++ homeassistant/components/kiosker/sensor.py | 189 +++++++++++++++ .../components/kiosker/services.yaml | 66 ++++++ homeassistant/components/kiosker/strings.json | 195 ++++++++++++++++ homeassistant/components/kiosker/switch.py | 61 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 3 + 18 files changed, 1299 insertions(+) create mode 100644 homeassistant/components/kiosker/Claude.md create mode 100644 homeassistant/components/kiosker/__init__.py create mode 100644 homeassistant/components/kiosker/config_flow.py create mode 100644 homeassistant/components/kiosker/const.py create mode 100644 homeassistant/components/kiosker/coordinator.py create mode 100644 homeassistant/components/kiosker/entity.py create mode 100644 homeassistant/components/kiosker/icons.json create mode 100644 homeassistant/components/kiosker/manifest.json create mode 100644 homeassistant/components/kiosker/quality_scale.yaml create mode 100644 homeassistant/components/kiosker/sensor.py create mode 100644 homeassistant/components/kiosker/services.yaml create mode 100644 homeassistant/components/kiosker/strings.json create mode 100644 homeassistant/components/kiosker/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 706083541ca53..98e4eaf0b4603 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -877,6 +877,7 @@ build.json @home-assistant/supervisor /homeassistant/components/keyboard_remote/ @bendavid @lanrat /homeassistant/components/keymitt_ble/ @spycle /tests/components/keymitt_ble/ @spycle +/homeassistant/components/kiosker/ @Claeysson /homeassistant/components/kitchen_sink/ @home-assistant/core /tests/components/kitchen_sink/ @home-assistant/core /homeassistant/components/kmtronic/ @dgomes diff --git a/homeassistant/components/kiosker/Claude.md b/homeassistant/components/kiosker/Claude.md new file mode 100644 index 0000000000000..6d37bfc34e8b8 --- /dev/null +++ b/homeassistant/components/kiosker/Claude.md @@ -0,0 +1,55 @@ +# Description +Home assistant component based on PyPi kiosker-python-api. +Call the component Kiosker + +Kiosker is an iOS web kiosk app. See description at https://kiosker.io, and https://docs.kiosker.io + +The component is an API integration for controlling the Kiosker app from Home Assistant. + +# Versions +Support Home Assistant v. 25 +Use kiosker-python-api version 1.2.3 from PyPi + +# Set up flow +- Let the user set up Kiosker visually. +- Let the user configure the connection by host, port (default 8081), API token, SSL (bool), and poll interval (default 30s). +- Allow multiple Kiosker instances. + +# Switches +- Disable screensaver: Set: screensaver_set_disabled_state(disabled=bool), Get: state = screensaver_get_state() state.disabled. + +# Actions +- Navigate to URL: navigate_url(url) +- Refresh: navigate_refresh() +- Navigate home: navigate_home() +- Navigate backward: navigate_backward() +- Navigate forward: navigate_forward() + +- Print: print() + +- Clear cookies: clear_cookies() +- Clear cache: clear_cache() + +- Interact with screensaver: screensaver_interact() + +- Blackout: blackout_set(Blackout(visible=True, text='This is a test from Python that should clear', background='#000000', foreground='#FFFFFF', icon='ladybug', expire=20) + +# States +- Device ID: status.device_id +- Model: status.model +- Sw Version: status.os_version +- Battery level: status.battery_level +- Battery state: status.battery_state +- Last interaction: status.last_interaction +- Last motion: status.last_motion +- Last status update: status.last_update + +- Blackout state: blackout_get(), return null if no blackout, else returns a json object. + +# Workflow +- The starting scheme/files in the root folder is generated by a Home Assistant scaffold witch should be used as a starting point. +- Always check your code after changes. +- Use modern Python. +- Look at "api.py", and "data.py" for library usage. +- Main language is English. +- Confirm to quality_scale bronze. \ No newline at end of file diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py new file mode 100644 index 0000000000000..fd1210bcdff80 --- /dev/null +++ b/homeassistant/components/kiosker/__init__.py @@ -0,0 +1,206 @@ +"""The Kiosker integration.""" + +from __future__ import annotations + +try: + from kiosker import Blackout, KioskerAPI +except ImportError: + # Handle missing dependency during development/translation scanning + Blackout = None + KioskerAPI = None + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + ATTR_BACKGROUND, + ATTR_BUTTON_BACKGROUND, + ATTR_BUTTON_FOREGROUND, + ATTR_BUTTON_TEXT, + ATTR_DISMISSIBLE, + ATTR_EXPIRE, + ATTR_FOREGROUND, + ATTR_ICON, + ATTR_SOUND, + ATTR_TEXT, + ATTR_URL, + ATTR_VISIBLE, + CONF_API_TOKEN, + CONF_POLL_INTERVAL, + CONF_SSL, + SERVICE_BLACKOUT_CLEAR, + SERVICE_BLACKOUT_SET, + SERVICE_CLEAR_CACHE, + SERVICE_CLEAR_COOKIES, + SERVICE_NAVIGATE_BACKWARD, + SERVICE_NAVIGATE_FORWARD, + SERVICE_NAVIGATE_HOME, + SERVICE_NAVIGATE_REFRESH, + SERVICE_NAVIGATE_URL, + SERVICE_PRINT, + SERVICE_SCREENSAVER_INTERACT, +) +from .coordinator import KioskerDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] + +type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] + + +def convert_rgb_to_hex(color: str | list[int]) -> str: + """Convert RGB color to hex format.""" + if isinstance(color, str): + # If already a string, assume it's hex or named color + if color.startswith("#"): + return color + # Handle named colors or other formats + return color + if isinstance(color, list) and len(color) == 3: + # Convert RGB list [r, g, b] to hex format + r, g, b = [int(x) for x in color] + return f"#{r:02x}{g:02x}{b:02x}" + # Fallback to default if conversion fails + return "#000000" + + +async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: + """Set up Kiosker from a config entry.""" + if KioskerAPI is None: + raise ConfigEntryNotReady("Kiosker dependency not available") + + api = KioskerAPI( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + token=entry.data[CONF_API_TOKEN], + ssl=entry.data[CONF_SSL], + ) + + coordinator = KioskerDataUpdateCoordinator( + hass, + api, + entry.data[CONF_POLL_INTERVAL], + ) + + await coordinator.async_config_entry_first_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + # Register services + async def navigate_url(call: ServiceCall) -> None: + """Navigate to URL service.""" + url = call.data[ATTR_URL] + await hass.async_add_executor_job(api.navigate_url, url) + + async def navigate_refresh(call: ServiceCall) -> None: + """Refresh page service.""" + await hass.async_add_executor_job(api.navigate_refresh) + + async def navigate_home(call: ServiceCall) -> None: + """Navigate home service.""" + await hass.async_add_executor_job(api.navigate_home) + + async def navigate_backward(call: ServiceCall) -> None: + """Navigate backward service.""" + await hass.async_add_executor_job(api.navigate_backward) + + async def navigate_forward(call: ServiceCall) -> None: + """Navigate forward service.""" + await hass.async_add_executor_job(api.navigate_forward) + + async def print_page(call: ServiceCall) -> None: + """Print page service.""" + await hass.async_add_executor_job(api.print) + + async def clear_cookies(call: ServiceCall) -> None: + """Clear cookies service.""" + await hass.async_add_executor_job(api.clear_cookies) + + async def clear_cache(call: ServiceCall) -> None: + """Clear cache service.""" + await hass.async_add_executor_job(api.clear_cache) + + async def screensaver_interact(call: ServiceCall) -> None: + """Interact with screensaver service.""" + await hass.async_add_executor_job(api.screensaver_interact) + + async def blackout_set(call: ServiceCall) -> None: + """Set blackout service.""" + if Blackout is None: + return + + # Convert RGB values to hex format + background_color = convert_rgb_to_hex(call.data.get(ATTR_BACKGROUND, "#000000")) + foreground_color = convert_rgb_to_hex(call.data.get(ATTR_FOREGROUND, "#FFFFFF")) + button_background_color = ( + convert_rgb_to_hex(call.data[ATTR_BUTTON_BACKGROUND]) + if call.data.get(ATTR_BUTTON_BACKGROUND) + else None + ) + button_foreground_color = ( + convert_rgb_to_hex(call.data[ATTR_BUTTON_FOREGROUND]) + if call.data.get(ATTR_BUTTON_FOREGROUND) + else None + ) + + blackout = Blackout( + visible=call.data.get(ATTR_VISIBLE, True), + text=call.data.get(ATTR_TEXT, ""), + background=background_color, + foreground=foreground_color, + icon=call.data.get(ATTR_ICON, ""), + expire=call.data.get(ATTR_EXPIRE, 0), + dismissible=call.data.get(ATTR_DISMISSIBLE, False), + buttonBackground=button_background_color, + buttonForeground=button_foreground_color, + buttonText=call.data.get(ATTR_BUTTON_TEXT, None), + sound=call.data.get(ATTR_SOUND, None), + ) + await hass.async_add_executor_job(api.blackout_set, blackout) + + async def blackout_clear(call: ServiceCall) -> None: + """Clear blackout service.""" + await hass.async_add_executor_job(api.blackout_clear) + await coordinator.async_request_refresh() + + hass.services.async_register("kiosker", SERVICE_NAVIGATE_URL, navigate_url) + hass.services.async_register("kiosker", SERVICE_NAVIGATE_REFRESH, navigate_refresh) + hass.services.async_register("kiosker", SERVICE_NAVIGATE_HOME, navigate_home) + hass.services.async_register( + "kiosker", SERVICE_NAVIGATE_BACKWARD, navigate_backward + ) + hass.services.async_register("kiosker", SERVICE_NAVIGATE_FORWARD, navigate_forward) + hass.services.async_register("kiosker", SERVICE_PRINT, print_page) + hass.services.async_register("kiosker", SERVICE_CLEAR_COOKIES, clear_cookies) + hass.services.async_register("kiosker", SERVICE_CLEAR_CACHE, clear_cache) + hass.services.async_register( + "kiosker", SERVICE_SCREENSAVER_INTERACT, screensaver_interact + ) + hass.services.async_register("kiosker", SERVICE_BLACKOUT_SET, blackout_set) + hass.services.async_register("kiosker", SERVICE_BLACKOUT_CLEAR, blackout_clear) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: + """Unload a config entry.""" + # Remove services + hass.services.async_remove("kiosker", SERVICE_NAVIGATE_URL) + hass.services.async_remove("kiosker", SERVICE_NAVIGATE_REFRESH) + hass.services.async_remove("kiosker", SERVICE_NAVIGATE_HOME) + hass.services.async_remove("kiosker", SERVICE_NAVIGATE_BACKWARD) + hass.services.async_remove("kiosker", SERVICE_NAVIGATE_FORWARD) + hass.services.async_remove("kiosker", SERVICE_PRINT) + hass.services.async_remove("kiosker", SERVICE_CLEAR_COOKIES) + hass.services.async_remove("kiosker", SERVICE_CLEAR_CACHE) + hass.services.async_remove("kiosker", SERVICE_SCREENSAVER_INTERACT) + hass.services.async_remove("kiosker", SERVICE_BLACKOUT_SET) + hass.services.async_remove("kiosker", SERVICE_BLACKOUT_CLEAR) + + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py new file mode 100644 index 0000000000000..ce59b03763a5f --- /dev/null +++ b/homeassistant/components/kiosker/config_flow.py @@ -0,0 +1,220 @@ +"""Config flow for the Kiosker integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from kiosker import KioskerAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow as HAConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import ( + CONF_API_TOKEN, + CONF_POLL_INTERVAL, + CONF_SSL, + CONF_SSL_VERIFY, + DEFAULT_POLL_INTERVAL, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_SSL_VERIFY, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_API_TOKEN): str, + vol.Optional(CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL): int, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + api = KioskerAPI( + host=data[CONF_HOST], + port=data[CONF_PORT], + token=data[CONF_API_TOKEN], + ssl=data[CONF_SSL], + ) + + try: + # Test connection by getting status + status = await hass.async_add_executor_job(api.status) + except Exception as exc: + _LOGGER.error("Failed to connect to Kiosker: %s", exc) + raise + + # Return info that you want to store in the config entry + device_id = status.device_id if hasattr(status, "device_id") else data[CONF_HOST] + return {"title": f"Kiosker {device_id}"} + + +class ConfigFlow(HAConfigFlow, domain=DOMAIN): + """Handle a config flow for Kiosker.""" + + VERSION = 1 + CONNECTION_CLASS = "local_polling" + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self._discovered_host: str | None = None + self._discovered_port: int | None = None + self._discovered_uuid: str | None = None + self._discovered_version: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + # Use host:port as unique identifier to prevent duplicate entries + unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + host = discovery_info.host + port = discovery_info.port or DEFAULT_PORT + + # Extract device information from zeroconf properties + properties = discovery_info.properties + uuid = properties.get("uuid") + app_name = properties.get("app", "Kiosker") + version = properties.get("version", "") + + # Create device name from available information + if uuid: + device_name = f"{app_name} ({uuid[:8].upper()})" + unique_id = uuid + else: + device_name = f"{app_name} {host}" + unique_id = f"{host}:{port}" + + # Set unique ID and check for duplicates + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host, CONF_PORT: port}) + + # Store discovery info for confirmation step + self.context["title_placeholders"] = { + "name": device_name, + "host": host, + "port": str(port), + } + + # Store discovered information for later use + self._discovered_host = host + self._discovered_port = port + self._discovered_uuid = uuid + self._discovered_version = version + + # Show confirmation dialog + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders=self.context["title_placeholders"], + ) + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle zeroconf confirmation.""" + if user_input is not None: + # User confirmed, proceed to get API token + return await self.async_step_discovery_confirm() + + # Show confirmation form with the stored title placeholders + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders=self.context["title_placeholders"], + ) + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + errors: dict[str, str] = {} + if user_input is not None: + # Use stored discovery info + host = self._discovered_host + port = self._discovered_port + + # Create config with discovered host/port and user-provided token + config_data = { + CONF_HOST: host, + CONF_PORT: port, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + CONF_POLL_INTERVAL: user_input.get( + CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL + ), + CONF_SSL: user_input.get(CONF_SSL, DEFAULT_SSL), + CONF_SSL_VERIFY: user_input.get(CONF_SSL_VERIFY, True), + } + + try: + info = await validate_input(self.hass, config_data) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during discovery validation") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=config_data) + + # Show form to get API token for discovered device + discovery_schema = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Optional(CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL): int, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, + } + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=discovery_schema, + description_placeholders=self.context["title_placeholders"], + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py new file mode 100644 index 0000000000000..ed3ad566829ce --- /dev/null +++ b/homeassistant/components/kiosker/const.py @@ -0,0 +1,42 @@ +"""Constants for the Kiosker integration.""" + +DOMAIN = "kiosker" + +# Configuration keys +CONF_API_TOKEN = "api_token" +CONF_SSL = "ssl" +CONF_SSL_VERIFY = "ssl_verify" +CONF_POLL_INTERVAL = "poll_interval" + +# Default values +DEFAULT_PORT = 8081 +DEFAULT_POLL_INTERVAL = 30 +DEFAULT_SSL = False +DEFAULT_SSL_VERIFY = False + +# Service names +SERVICE_NAVIGATE_URL = "navigate_url" +SERVICE_NAVIGATE_REFRESH = "navigate_refresh" +SERVICE_NAVIGATE_HOME = "navigate_home" +SERVICE_NAVIGATE_BACKWARD = "navigate_backward" +SERVICE_NAVIGATE_FORWARD = "navigate_forward" +SERVICE_PRINT = "print" +SERVICE_CLEAR_COOKIES = "clear_cookies" +SERVICE_CLEAR_CACHE = "clear_cache" +SERVICE_SCREENSAVER_INTERACT = "screensaver_interact" +SERVICE_BLACKOUT_SET = "blackout_set" +SERVICE_BLACKOUT_CLEAR = "blackout_clear" + +# Attributes +ATTR_URL = "url" +ATTR_VISIBLE = "visible" +ATTR_TEXT = "text" +ATTR_BACKGROUND = "background" +ATTR_FOREGROUND = "foreground" +ATTR_ICON = "icon" +ATTR_EXPIRE = "expire" +ATTR_DISMISSIBLE = "dismissible" +ATTR_BUTTON_BACKGROUND = "button_background" +ATTR_BUTTON_FOREGROUND = "button_foreground" +ATTR_BUTTON_TEXT = "button_text" +ATTR_SOUND = "sound" diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py new file mode 100644 index 0000000000000..541162dfbec3c --- /dev/null +++ b/homeassistant/components/kiosker/coordinator.py @@ -0,0 +1,51 @@ +"""DataUpdateCoordinator for Kiosker.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from kiosker import KioskerAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class KioskerDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the Kiosker API.""" + + def __init__( + self, + hass: HomeAssistant, + api: KioskerAPI, + poll_interval: int, + ) -> None: + """Initialize.""" + self.api = api + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=poll_interval), + ) + + async def _async_update_data(self) -> dict: + """Update data via library.""" + try: + status = await self.hass.async_add_executor_job(self.api.status) + blackout = await self.hass.async_add_executor_job(self.api.blackout_get) + screensaver = await self.hass.async_add_executor_job( + self.api.screensaver_get_state + ) + except Exception as exception: + raise UpdateFailed(exception) from exception + else: + return { + "status": status, + "blackout": blackout, + "screensaver": screensaver, + } diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py new file mode 100644 index 0000000000000..699aa975e8556 --- /dev/null +++ b/homeassistant/components/kiosker/entity.py @@ -0,0 +1,87 @@ +"""Base entity for Kiosker.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import KioskerDataUpdateCoordinator + + +class KioskerEntity(CoordinatorEntity[KioskerDataUpdateCoordinator]): + """Base class for Kiosker entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: KioskerDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + # Get device info with fallbacks for translation detection + device_id = self._get_device_id() + model = self._get_model() + hw_version = self._get_hw_version() + sw_version = self._get_sw_version() + app_name = self._get_app_name() + + # Ensure device info is always created, even without coordinator data + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=f"Kiosker {device_id[:8]}" if device_id != "unknown" else "Kiosker", + manufacturer="Top North", + model=app_name, + sw_version=sw_version, + hw_version=f"{model} ({hw_version})", + serial_number=device_id, + ) + + def _get_device_id(self) -> str: + """Get device ID from coordinator data.""" + if ( + self.coordinator.data + and "status" in self.coordinator.data + and hasattr(self.coordinator.data["status"], "device_id") + ): + return self.coordinator.data["status"].device_id + return "unknown" + + def _get_app_name(self) -> str: + """Get app name from coordinator data.""" + if ( + self.coordinator.data + and "status" in self.coordinator.data + and hasattr(self.coordinator.data["status"], "app_name") + ): + return self.coordinator.data["status"].app_name + return "Unknown" + + def _get_model(self) -> str: + """Get model from coordinator data.""" + if ( + self.coordinator.data + and "status" in self.coordinator.data + and hasattr(self.coordinator.data["status"], "model") + ): + return self.coordinator.data["status"].model + return "Unknown" + + def _get_sw_version(self) -> str: + """Get software version from coordinator data.""" + if ( + self.coordinator.data + and "status" in self.coordinator.data + and hasattr(self.coordinator.data["status"], "app_version") + ): + return self.coordinator.data["status"].app_version + return "Unknown" + + def _get_hw_version(self) -> str: + """Get software version from coordinator data.""" + if ( + self.coordinator.data + and "status" in self.coordinator.data + and hasattr(self.coordinator.data["status"], "os_version") + ): + return self.coordinator.data["status"].os_version + return "Unknown" diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json new file mode 100644 index 0000000000000..6034f7783d0b0 --- /dev/null +++ b/homeassistant/components/kiosker/icons.json @@ -0,0 +1,37 @@ +{ + "services": { + "navigate_url": { + "service": "mdi:web" + }, + "navigate_refresh": { + "service": "mdi:refresh" + }, + "navigate_home": { + "service": "mdi:home" + }, + "navigate_backward": { + "service": "mdi:arrow-left" + }, + "navigate_forward": { + "service": "mdi:arrow-right" + }, + "print": { + "service": "mdi:printer" + }, + "clear_cookies": { + "service": "mdi:cookie-remove" + }, + "clear_cache": { + "service": "mdi:cached" + }, + "screensaver_interact": { + "service": "mdi:monitor-screenshot" + }, + "blackout_set": { + "service": "mdi:monitor-off" + }, + "blackout_clear": { + "service": "mdi:monitor" + } + } +} diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json new file mode 100644 index 0000000000000..bfaf133b29474 --- /dev/null +++ b/homeassistant/components/kiosker/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "kiosker", + "name": "Kiosker", + "codeowners": ["@Claeysson"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/kiosker", + "homekit": {}, + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["kiosker-python-api==1.2.4"], + "ssdp": [], + "zeroconf": ["_kiosker._tcp.local."] +} diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml new file mode 100644 index 0000000000000..76b8d347408bf --- /dev/null +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py new file mode 100644 index 0000000000000..114b79d057ef8 --- /dev/null +++ b/homeassistant/components/kiosker/sensor.py @@ -0,0 +1,189 @@ +"""Sensor platform for Kiosker.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import KioskerConfigEntry +from .coordinator import KioskerDataUpdateCoordinator +from .entity import KioskerEntity + + +@dataclass(frozen=True) +class KioskerSensorEntityDescription(SensorEntityDescription): + """Kiosker sensor description.""" + + value_fn: Callable[[Any], StateType] | None = None + + +def parse_datetime(value: Any) -> datetime | None: + """Parse datetime from various formats.""" + if value is None: + return None + if isinstance(value, datetime): + return value + try: + return datetime.fromisoformat(str(value)) + except (ValueError, TypeError): + return None + + +SENSORS: tuple[KioskerSensorEntityDescription, ...] = ( + KioskerSensorEntityDescription( + key="batteryLevel", + translation_key="battery_level", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.battery_level if hasattr(x, "battery_level") else None, + ), + KioskerSensorEntityDescription( + key="batteryState", + translation_key="battery_state", + icon="mdi:lightning-bolt", + value_fn=lambda x: x.battery_state if hasattr(x, "battery_state") else None, + ), + KioskerSensorEntityDescription( + key="lastInteraction", + translation_key="last_interaction", + icon="mdi:gesture-tap", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda x: ( + dt.isoformat() if (dt := parse_datetime(x.last_interaction)) else None + ) + if hasattr(x, "last_interaction") + else None, + ), + KioskerSensorEntityDescription( + key="lastMotion", + translation_key="last_motion", + icon="mdi:motion-sensor", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda x: ( + dt.isoformat() if (dt := parse_datetime(x.last_motion)) else None + ) + if hasattr(x, "last_motion") + else None, + ), + KioskerSensorEntityDescription( + key="lastUpdate", + translation_key="last_update", + icon="mdi:update", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda x: ( + dt.isoformat() if (dt := parse_datetime(x.last_update)) else None + ) + if hasattr(x, "last_update") + else None, + ), + KioskerSensorEntityDescription( + key="blackoutState", + translation_key="blackout_state", + icon="mdi:monitor-off", + value_fn=lambda x: "active" if x is not None else "inactive", + ), + KioskerSensorEntityDescription( + key="screensaverVisibility", + translation_key="screensaver_visibility", + icon="mdi:power-sleep", + value_fn=lambda x: "visible" + if hasattr(x, "visible") and x.visible + else "hidden", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KioskerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Kiosker sensors based on a config entry.""" + coordinator = entry.runtime_data + + # Create all sensors - they will handle missing data gracefully + sensors = [KioskerSensor(coordinator, description) for description in SENSORS] + + async_add_entities(sensors) + + +class KioskerSensor(KioskerEntity, SensorEntity): + """Representation of a Kiosker sensor.""" + + entity_description: KioskerSensorEntityDescription + + def __init__( + self, + coordinator: KioskerDataUpdateCoordinator, + description: KioskerSensorEntityDescription, + ) -> None: + """Initialize the sensor entity.""" + + self.entity_description = description + + super().__init__(coordinator) + + # Get device ID properly for unique_id + device_id = self._get_device_id() + self._attr_unique_id = f"{device_id}_{description.translation_key}" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + value = None + + if self.entity_description.key == "blackoutState": + # Special handling for blackout state + blackout_data = ( + self.coordinator.data.get("blackout") if self.coordinator.data else None + ) + if self.entity_description.value_fn: + value = self.entity_description.value_fn(blackout_data) + + # Add all blackout data as extra attributes + if blackout_data is not None and hasattr(blackout_data, "__dict__"): + self._attr_extra_state_attributes = { + key: value + for key, value in blackout_data.__dict__.items() + if not key.startswith("_") + } + else: + self._attr_extra_state_attributes = {} + elif self.entity_description.key == "screensaverVisibility": + # Special handling for screensaver visibility + screensaver_data = ( + self.coordinator.data.get("screensaver") + if self.coordinator.data + else None + ) + if self.entity_description.value_fn: + value = self.entity_description.value_fn(screensaver_data) + # Clear extra attributes for screensaver sensor + self._attr_extra_state_attributes = {} + elif self.coordinator.data and "status" in self.coordinator.data: + # Handle status-based sensors + status = self.coordinator.data["status"] + if self.entity_description.value_fn: + value = self.entity_description.value_fn(status) + # Clear extra attributes for non-blackout sensors + self._attr_extra_state_attributes = {} + else: + # Clear extra attributes if no data + self._attr_extra_state_attributes = {} + + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/kiosker/services.yaml b/homeassistant/components/kiosker/services.yaml new file mode 100644 index 0000000000000..3f8e42be354c3 --- /dev/null +++ b/homeassistant/components/kiosker/services.yaml @@ -0,0 +1,66 @@ +navigate_url: + fields: + url: + required: true + selector: + text: + +navigate_refresh: + +navigate_home: + +navigate_backward: + +navigate_forward: + +print: + +clear_cookies: + +clear_cache: + +screensaver_interact: + +blackout_clear: + +blackout_set: + fields: + visible: + default: true + selector: + boolean: + text: + default: "Hello, World!" + selector: + text: + background: + selector: + color_rgb: + foreground: + selector: + color_rgb: + icon: + selector: + text: + dismissible: + selector: + boolean: + button_background: + selector: + color_rgb: + button_foreground: + selector: + color_rgb: + button_text: + selector: + text: + sound: + selector: + text: + expire: + default: 60 + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json new file mode 100644 index 0000000000000..b8e640f23ffc0 --- /dev/null +++ b/homeassistant/components/kiosker/strings.json @@ -0,0 +1,195 @@ +{ + "config": { + "step": { + "user": { + "title": "Pair Kiosker App", + "description": "Enable the API in Kiosker settings to pair with Home Assistant.", + "data": { + "host": "Host", + "port": "Port", + "api_token": "API Token", + "ssl": "Use SSL", + "ssl_verify": "Verify certificate", + "poll_interval": "Poll Interval (seconds)" + }, + "data_description": { + "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", + "host": "The hostname or IP address of the device running the Kiosker App", + "port": "The port on which the Kiosker App is running. Default is 8081.", + "poll_interval": "The interval in seconds to poll the Kiosker App for updates.", + "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", + "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." + } + }, + "zeroconf": { + "description": "Do you want to configure {name} at {host}:{port}?" + }, + "zeroconf_confirm": { + "title": "Discovered Kiosker App", + "description": "You are about to pair `{name}` at `{host}:{port}` with Home Assistant.\n\n**Would you like to proceed?**", + "submit": "Pair" + }, + "discovery_confirm": { + "title": "Pair Kiosker App", + "description": "Pair `{name}` at `{host}:{port}`", + "data": { + "api_token": "API Token", + "poll_interval": "Poll Interval (seconds)", + "ssl": "Use SSL", + "ssl_verify": "Verify certificate" + }, + "data_description": { + "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", + "poll_interval": "The interval in seconds to poll the Kiosker App for updates.", + "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", + "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Kiosker device", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + } + }, + "entity": { + "switch": { + "disable_screensaver": { + "name": "Disable screensaver" + } + }, + "sensor": { + "battery_level": { + "name": "Battery level" + }, + "battery_state": { + "name": "Battery state", + "state": { + "unkown": "Unknwown", + "not_charging": "Not Charging", + "fully_charged": "Fully Charged" + } + }, + "blackout_state": { + "name": "Blackout state", + "state": { + "active": "Active", + "inactive": "Inactive" + } + }, + "last_interaction": { + "name": "Last interaction" + }, + "last_motion": { + "name": "Last motion" + }, + "last_update": { + "name": "Last update" + }, + "screensaver_visibility": { + "name": "Screensaver visibility", + "state": { + "visible": "Visible", + "hidden": "Hidden" + } + } + } + }, + "services": { + "navigate_url": { + "name": "Navigate to URL", + "description": "Navigate to a specific URL", + "fields": { + "url": { + "name": "URL", + "description": "The URL to navigate to" + } + } + }, + "navigate_refresh": { + "name": "Refresh", + "description": "Refresh the current page" + }, + "navigate_home": { + "name": "Navigate home", + "description": "Navigate to the home page" + }, + "navigate_backward": { + "name": "Navigate backward", + "description": "Navigate to the previous page" + }, + "navigate_forward": { + "name": "Navigate forward", + "description": "Navigate to the next page" + }, + "print": { + "name": "Print", + "description": "Print the current page" + }, + "clear_cookies": { + "name": "Clear cookies", + "description": "Clear all cookies" + }, + "clear_cache": { + "name": "Clear cache", + "description": "Clear the browser cache" + }, + "screensaver_interact": { + "name": "Screensaver interact", + "description": "Interact with the screensaver" + }, + "blackout_set": { + "name": "Set blackout", + "description": "Set blackout screen with custom message", + "fields": { + "visible": { + "name": "Visible", + "description": "Whether the blackout is visible" + }, + "text": { + "name": "Text", + "description": "Text to display on blackout screen" + }, + "background": { + "name": "Background color", + "description": "Background color in hex format" + }, + "foreground": { + "name": "Foreground color", + "description": "Text color in hex format" + }, + "icon": { + "name": "Icon", + "description": "Icon to display (SF Symbols name)" + }, + "expire": { + "name": "Expire time", + "description": "Time in seconds before blackout expires" + }, + "dismissible": { + "name": "Dismissible", + "description": "Whether the blackout can be dismissed by user interaction" + }, + "button_background": { + "name": "Button background color", + "description": "Background color of the dismiss button in hex format" + }, + "button_foreground": { + "name": "Button foreground color", + "description": "Text color of the dismiss button in hex format" + }, + "button_text": { + "name": "Button text", + "description": "Text to display on the dismiss button" + }, + "sound": { + "name": "Sound", + "description": "Sound to play when blackout is displayed (SystemSoundID, eg: 1007)" + } + } + } + } +} diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py new file mode 100644 index 0000000000000..f5eabd586aa11 --- /dev/null +++ b/homeassistant/components/kiosker/switch.py @@ -0,0 +1,61 @@ +"""Switch platform for Kiosker.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import KioskerConfigEntry +from .coordinator import KioskerDataUpdateCoordinator +from .entity import KioskerEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KioskerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Kiosker switch based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities([KioskerScreensaverSwitch(coordinator)]) + + +class KioskerScreensaverSwitch(KioskerEntity, SwitchEntity): + """Screensaver disable switch for Kiosker.""" + + _has_entity_name = True + _attr_translation_key = "disable_screensaver" + + def __init__(self, coordinator: KioskerDataUpdateCoordinator) -> None: + """Initialize the screensaver switch.""" + + super().__init__(coordinator) + + device_id = self._get_device_id() + self._attr_unique_id = f"{device_id}_{self._attr_translation_key}" + + @property + def is_on(self) -> bool | None: + """Return true if screensaver is disabled.""" + if self.coordinator.data and "screensaver" in self.coordinator.data: + screensaver_state = self.coordinator.data["screensaver"] + if hasattr(screensaver_state, "disabled"): + return screensaver_state.disabled + return None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on (disable screensaver).""" + await self.hass.async_add_executor_job( + self.coordinator.api.screensaver_set_disabled_state, True + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off (enable screensaver).""" + await self.hass.async_add_executor_job( + self.coordinator.api.screensaver_set_disabled_state, False + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1086fad04be77..1062c7743ab31 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -361,6 +361,7 @@ "keenetic_ndms2", "kegtron", "keymitt_ble", + "kiosker", "kmtronic", "knocki", "knx", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bb327ef8fbe85..05657cd629806 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3425,6 +3425,12 @@ "config_flow": true, "iot_class": "assumed_state" }, + "kiosker": { + "name": "Kiosker", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "kira": { "name": "Kira", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index b6d1148597ed8..2d3c480c981ef 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -689,6 +689,11 @@ "domain": "ipp", }, ], + "_kiosker._tcp.local.": [ + { + "domain": "kiosker", + }, + ], "_kizbox._tcp.local.": [ { "domain": "overkiz", diff --git a/requirements_all.txt b/requirements_all.txt index e7438322e2b99..f498f1ca77c50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1367,6 +1367,9 @@ keba-kecontact==1.3.0 # homeassistant.components.kegtron kegtron-ble==1.0.2 +# homeassistant.components.kiosker +kiosker-python-api==1.2.4 + # homeassistant.components.kiwi kiwiki-client==0.1.1 From 1181ddf41134bcb03b5a679b55c8484f91764066 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Fri, 20 Jun 2025 23:18:46 +0200 Subject: [PATCH 02/69] Kiosker --- .claude/settings.local.json | 6 ++++++ homeassistant/components/kiosker/.gitignore | 2 ++ homeassistant/components/kiosker/__init__.py | 2 ++ homeassistant/components/kiosker/config_flow.py | 3 ++- homeassistant/components/kiosker/manifest.json | 2 +- homeassistant/components/kiosker/sensor.py | 14 ++++---------- requirements_all.txt | 2 +- 7 files changed, 18 insertions(+), 13 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 homeassistant/components/kiosker/.gitignore diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000000..246c74973079d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,6 @@ +{ + "permissions": { + "allow": ["mcp__ide__getDiagnostics"], + "deny": [] + } +} diff --git a/homeassistant/components/kiosker/.gitignore b/homeassistant/components/kiosker/.gitignore new file mode 100644 index 0000000000000..976fae24ab7c8 --- /dev/null +++ b/homeassistant/components/kiosker/.gitignore @@ -0,0 +1,2 @@ +claude.md +.claude/ \ No newline at end of file diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index fd1210bcdff80..7458dc696b3b4 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -30,6 +30,7 @@ CONF_API_TOKEN, CONF_POLL_INTERVAL, CONF_SSL, + CONF_SSL_VERIFY, SERVICE_BLACKOUT_CLEAR, SERVICE_BLACKOUT_SET, SERVICE_CLEAR_CACHE, @@ -75,6 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> b port=entry.data[CONF_PORT], token=entry.data[CONF_API_TOKEN], ssl=entry.data[CONF_SSL], + verify=entry.data.get(CONF_SSL_VERIFY, False), ) coordinator = KioskerDataUpdateCoordinator( diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index ce59b03763a5f..85da83ec91976 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -50,6 +50,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, port=data[CONF_PORT], token=data[CONF_API_TOKEN], ssl=data[CONF_SSL], + verify=data[CONF_SSL_VERIFY], ) try: @@ -185,7 +186,7 @@ async def async_step_discovery_confirm( CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL ), CONF_SSL: user_input.get(CONF_SSL, DEFAULT_SSL), - CONF_SSL_VERIFY: user_input.get(CONF_SSL_VERIFY, True), + CONF_SSL_VERIFY: user_input.get(CONF_SSL_VERIFY, DEFAULT_SSL_VERIFY), } try: diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index bfaf133b29474..2bd350fb20a15 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -8,7 +8,7 @@ "homekit": {}, "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["kiosker-python-api==1.2.4"], + "requirements": ["kiosker-python-api==1.2.5"], "ssdp": [], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index 114b79d057ef8..d8ead9593368b 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -27,7 +27,7 @@ class KioskerSensorEntityDescription(SensorEntityDescription): """Kiosker sensor description.""" - value_fn: Callable[[Any], StateType] | None = None + value_fn: Callable[[Any], StateType | datetime] | None = None def parse_datetime(value: Any) -> datetime | None: @@ -62,9 +62,7 @@ def parse_datetime(value: Any) -> datetime | None: translation_key="last_interaction", icon="mdi:gesture-tap", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: ( - dt.isoformat() if (dt := parse_datetime(x.last_interaction)) else None - ) + value_fn=lambda x: parse_datetime(x.last_interaction) if hasattr(x, "last_interaction") else None, ), @@ -73,9 +71,7 @@ def parse_datetime(value: Any) -> datetime | None: translation_key="last_motion", icon="mdi:motion-sensor", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: ( - dt.isoformat() if (dt := parse_datetime(x.last_motion)) else None - ) + value_fn=lambda x: parse_datetime(x.last_motion) if hasattr(x, "last_motion") else None, ), @@ -84,9 +80,7 @@ def parse_datetime(value: Any) -> datetime | None: translation_key="last_update", icon="mdi:update", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: ( - dt.isoformat() if (dt := parse_datetime(x.last_update)) else None - ) + value_fn=lambda x: parse_datetime(x.last_update) if hasattr(x, "last_update") else None, ), diff --git a/requirements_all.txt b/requirements_all.txt index f498f1ca77c50..79bc19666daea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1368,7 +1368,7 @@ keba-kecontact==1.3.0 kegtron-ble==1.0.2 # homeassistant.components.kiosker -kiosker-python-api==1.2.4 +kiosker-python-api==1.2.5 # homeassistant.components.kiwi kiwiki-client==0.1.1 From a2b457cc2c2831f6bdbf2043b889dfc538883e5e Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sun, 29 Jun 2025 22:13:42 +0200 Subject: [PATCH 03/69] Kiosker --- homeassistant/components/kiosker/__init__.py | 21 +++------- .../components/kiosker/config_flow.py | 42 +++++++++++++------ .../components/kiosker/manifest.json | 3 -- homeassistant/components/kiosker/strings.json | 10 ++--- 4 files changed, 41 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 7458dc696b3b4..dfc1613fe08a6 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -2,12 +2,7 @@ from __future__ import annotations -try: - from kiosker import Blackout, KioskerAPI -except ImportError: - # Handle missing dependency during development/translation scanning - Blackout = None - KioskerAPI = None +from kiosker import Blackout, KioskerAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform @@ -75,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> b host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], token=entry.data[CONF_API_TOKEN], - ssl=entry.data[CONF_SSL], + ssl=entry.data.get(CONF_SSL, False), verify=entry.data.get(CONF_SSL_VERIFY, False), ) @@ -140,15 +135,11 @@ async def blackout_set(call: ServiceCall) -> None: # Convert RGB values to hex format background_color = convert_rgb_to_hex(call.data.get(ATTR_BACKGROUND, "#000000")) foreground_color = convert_rgb_to_hex(call.data.get(ATTR_FOREGROUND, "#FFFFFF")) - button_background_color = ( - convert_rgb_to_hex(call.data[ATTR_BUTTON_BACKGROUND]) - if call.data.get(ATTR_BUTTON_BACKGROUND) - else None + button_background_color = convert_rgb_to_hex( + call.data.get(ATTR_BUTTON_BACKGROUND, "#FFFFFF") ) - button_foreground_color = ( - convert_rgb_to_hex(call.data[ATTR_BUTTON_FOREGROUND]) - if call.data.get(ATTR_BUTTON_FOREGROUND) - else None + button_foreground_color = convert_rgb_to_hex( + call.data.get(ATTR_BUTTON_FOREGROUND, "#000000") ) blackout = Blackout( diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 85da83ec91976..b6fa998ff3c34 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -58,7 +58,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, status = await hass.async_add_executor_job(api.status) except Exception as exc: _LOGGER.error("Failed to connect to Kiosker: %s", exc) - raise + raise CannotConnect from exc # Return info that you want to store in the config entry device_id = status.device_id if hasattr(status, "device_id") else data[CONF_HOST] @@ -69,6 +69,7 @@ class ConfigFlow(HAConfigFlow, domain=DOMAIN): """Handle a config flow for Kiosker.""" VERSION = 1 + MINOR_VERSION = 1 CONNECTION_CLASS = "local_polling" def __init__(self) -> None: @@ -85,16 +86,6 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - # Use host:port as unique identifier to prevent duplicate entries - unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - } - ) - try: info = await validate_input(self.hass, user_input) except CannotConnect: @@ -103,6 +94,33 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + # Get device info to determine unique ID + api = KioskerAPI( + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + token=user_input[CONF_API_TOKEN], + ssl=user_input[CONF_SSL], + verify=user_input[CONF_SSL_VERIFY], + ) + try: + status = await self.hass.async_add_executor_job(api.status) + device_id = ( + status.device_id + if hasattr(status, "device_id") + else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + ) + except Exception: # noqa: BLE001 + device_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + + # Use device ID as unique identifier + await self.async_set_unique_id(device_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( @@ -122,7 +140,7 @@ async def async_step_zeroconf( app_name = properties.get("app", "Kiosker") version = properties.get("version", "") - # Create device name from available information + # Use UUID from zeroconf if available, otherwise use host:port as fallback if uuid: device_name = f"{app_name} ({uuid[:8].upper()})" unique_id = uuid diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index 2bd350fb20a15..8512ac603081b 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -3,12 +3,9 @@ "name": "Kiosker", "codeowners": ["@Claeysson"], "config_flow": true, - "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/kiosker", - "homekit": {}, "iot_class": "local_polling", "quality_scale": "bronze", "requirements": ["kiosker-python-api==1.2.5"], - "ssdp": [], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index b8e640f23ffc0..15aa4d7f6fa9d 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -68,7 +68,7 @@ "battery_state": { "name": "Battery state", "state": { - "unkown": "Unknwown", + "unknown": "Unknown", "not_charging": "Not Charging", "fully_charged": "Fully Charged" } @@ -119,11 +119,11 @@ }, "navigate_backward": { "name": "Navigate backward", - "description": "Navigate to the previous page" + "description": "Navigate to the previous page in history" }, "navigate_forward": { "name": "Navigate forward", - "description": "Navigate to the next page" + "description": "Navigate to the next page in history" }, "print": { "name": "Print", @@ -167,7 +167,7 @@ }, "expire": { "name": "Expire time", - "description": "Time in seconds before blackout expires" + "description": "Time in seconds before the blackout expires" }, "dismissible": { "name": "Dismissible", @@ -187,7 +187,7 @@ }, "sound": { "name": "Sound", - "description": "Sound to play when blackout is displayed (SystemSoundID, eg: 1007)" + "description": "Sound to play when blackout is displayed (SystemSoundID, e.g., 1007)" } } } From ccd8fdf25d181acd141ad780e1f31dfe3190cd9b Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 23 Jul 2025 23:39:16 +0200 Subject: [PATCH 04/69] Kiosker --- .claude/settings.local.json | 2 +- CODEOWNERS | 1 + homeassistant/components/kiosker/__init__.py | 239 ++++++++---- .../components/kiosker/quality_scale.yaml | 8 +- .../components/kiosker/services.yaml | 52 ++- homeassistant/components/kiosker/strings.json | 4 + tests/components/kiosker/__init__.py | 1 + tests/components/kiosker/test_config_flow.py | 362 ++++++++++++++++++ 8 files changed, 573 insertions(+), 96 deletions(-) create mode 100644 tests/components/kiosker/__init__.py create mode 100644 tests/components/kiosker/test_config_flow.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 246c74973079d..159a20cc47d50 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,6 +1,6 @@ { "permissions": { - "allow": ["mcp__ide__getDiagnostics"], + "allow": ["mcp__ide__getDiagnostics", "Bash(rg:*)", "Bash(find:*)"], "deny": [] } } diff --git a/CODEOWNERS b/CODEOWNERS index 98e4eaf0b4603..453df199090b1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -878,6 +878,7 @@ build.json @home-assistant/supervisor /homeassistant/components/keymitt_ble/ @spycle /tests/components/keymitt_ble/ @spycle /homeassistant/components/kiosker/ @Claeysson +/tests/components/kiosker/ @Claeysson /homeassistant/components/kitchen_sink/ @home-assistant/core /tests/components/kitchen_sink/ @home-assistant/core /homeassistant/components/kmtronic/ @dgomes diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index dfc1613fe08a6..0783526f23834 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -8,6 +8,8 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.service import async_extract_referenced_entity_ids from .const import ( ATTR_BACKGROUND, @@ -26,6 +28,7 @@ CONF_POLL_INTERVAL, CONF_SSL, CONF_SSL_VERIFY, + DOMAIN, SERVICE_BLACKOUT_CLEAR, SERVICE_BLACKOUT_SET, SERVICE_CLEAR_CACHE, @@ -42,96 +45,111 @@ _PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] -type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] - - -def convert_rgb_to_hex(color: str | list[int]) -> str: - """Convert RGB color to hex format.""" - if isinstance(color, str): - # If already a string, assume it's hex or named color - if color.startswith("#"): - return color - # Handle named colors or other formats - return color - if isinstance(color, list) and len(color) == 3: - # Convert RGB list [r, g, b] to hex format - r, g, b = [int(x) for x in color] - return f"#{r:02x}{g:02x}{b:02x}" - # Fallback to default if conversion fails - return "#000000" +async def _get_target_coordinators( + hass: HomeAssistant, call: ServiceCall +) -> list[KioskerDataUpdateCoordinator]: + """Get coordinators for target devices.""" + coordinators: list[KioskerDataUpdateCoordinator] = [] -async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: - """Set up Kiosker from a config entry.""" - if KioskerAPI is None: - raise ConfigEntryNotReady("Kiosker dependency not available") + # Extract device targets from the service call + referenced = async_extract_referenced_entity_ids(hass, call, expand_group=True) + target_device_ids = referenced.referenced_devices - api = KioskerAPI( - host=entry.data[CONF_HOST], - port=entry.data[CONF_PORT], - token=entry.data[CONF_API_TOKEN], - ssl=entry.data.get(CONF_SSL, False), - verify=entry.data.get(CONF_SSL_VERIFY, False), - ) + # If no targets specified, target all kiosker devices + if not target_device_ids: + coordinators.extend( + config_entry.runtime_data + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.runtime_data + ) + return coordinators - coordinator = KioskerDataUpdateCoordinator( - hass, - api, - entry.data[CONF_POLL_INTERVAL], - ) + # Get device registry + device_registry = dr.async_get(hass) - await coordinator.async_config_entry_first_refresh() + # Find coordinators for target devices + for device_id in target_device_ids: + device = device_registry.async_get(device_id) + if device: + # Find the config entry for this device + for config_entry in hass.config_entries.async_entries(DOMAIN): + if ( + config_entry.runtime_data + and config_entry.entry_id in device.config_entries + ): + coordinators.append(config_entry.runtime_data) + break - if not coordinator.last_update_success: - raise ConfigEntryNotReady + return coordinators - entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) +async def _register_services(hass: HomeAssistant) -> None: + """Register Kiosker services.""" - # Register services async def navigate_url(call: ServiceCall) -> None: """Navigate to URL service.""" url = call.data[ATTR_URL] - await hass.async_add_executor_job(api.navigate_url, url) + coordinators = await _get_target_coordinators(hass, call) + + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.navigate_url, url) async def navigate_refresh(call: ServiceCall) -> None: """Refresh page service.""" - await hass.async_add_executor_job(api.navigate_refresh) + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.navigate_refresh) async def navigate_home(call: ServiceCall) -> None: """Navigate home service.""" - await hass.async_add_executor_job(api.navigate_home) + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.navigate_home) async def navigate_backward(call: ServiceCall) -> None: """Navigate backward service.""" - await hass.async_add_executor_job(api.navigate_backward) + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.navigate_backward) async def navigate_forward(call: ServiceCall) -> None: """Navigate forward service.""" - await hass.async_add_executor_job(api.navigate_forward) + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.navigate_forward) async def print_page(call: ServiceCall) -> None: """Print page service.""" - await hass.async_add_executor_job(api.print) + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.print) async def clear_cookies(call: ServiceCall) -> None: """Clear cookies service.""" - await hass.async_add_executor_job(api.clear_cookies) + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.clear_cookies) async def clear_cache(call: ServiceCall) -> None: """Clear cache service.""" - await hass.async_add_executor_job(api.clear_cache) + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.clear_cache) async def screensaver_interact(call: ServiceCall) -> None: """Interact with screensaver service.""" - await hass.async_add_executor_job(api.screensaver_interact) + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.screensaver_interact) async def blackout_set(call: ServiceCall) -> None: """Set blackout service.""" if Blackout is None: return + coordinators = await _get_target_coordinators(hass, call) + # Convert RGB values to hex format background_color = convert_rgb_to_hex(call.data.get(ATTR_BACKGROUND, "#000000")) foreground_color = convert_rgb_to_hex(call.data.get(ATTR_FOREGROUND, "#FFFFFF")) @@ -150,50 +168,109 @@ async def blackout_set(call: ServiceCall) -> None: icon=call.data.get(ATTR_ICON, ""), expire=call.data.get(ATTR_EXPIRE, 0), dismissible=call.data.get(ATTR_DISMISSIBLE, False), - buttonBackground=button_background_color, - buttonForeground=button_foreground_color, - buttonText=call.data.get(ATTR_BUTTON_TEXT, None), - sound=call.data.get(ATTR_SOUND, None), + button_background=button_background_color, + button_foreground=button_foreground_color, + button_text=call.data.get(ATTR_BUTTON_TEXT, "Dismiss"), + sound=call.data.get(ATTR_SOUND, 0), ) - await hass.async_add_executor_job(api.blackout_set, blackout) + + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.blackout_set, blackout) + await coordinator.async_request_refresh() async def blackout_clear(call: ServiceCall) -> None: """Clear blackout service.""" - await hass.async_add_executor_job(api.blackout_clear) - await coordinator.async_request_refresh() + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await hass.async_add_executor_job(coordinator.api.blackout_clear) + await coordinator.async_request_refresh() - hass.services.async_register("kiosker", SERVICE_NAVIGATE_URL, navigate_url) - hass.services.async_register("kiosker", SERVICE_NAVIGATE_REFRESH, navigate_refresh) - hass.services.async_register("kiosker", SERVICE_NAVIGATE_HOME, navigate_home) + # Register services + hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_URL, navigate_url) + hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_REFRESH, navigate_refresh) + hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_HOME, navigate_home) + hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_BACKWARD, navigate_backward) + hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_FORWARD, navigate_forward) + hass.services.async_register(DOMAIN, SERVICE_PRINT, print_page) + hass.services.async_register(DOMAIN, SERVICE_CLEAR_COOKIES, clear_cookies) + hass.services.async_register(DOMAIN, SERVICE_CLEAR_CACHE, clear_cache) hass.services.async_register( - "kiosker", SERVICE_NAVIGATE_BACKWARD, navigate_backward + DOMAIN, SERVICE_SCREENSAVER_INTERACT, screensaver_interact ) - hass.services.async_register("kiosker", SERVICE_NAVIGATE_FORWARD, navigate_forward) - hass.services.async_register("kiosker", SERVICE_PRINT, print_page) - hass.services.async_register("kiosker", SERVICE_CLEAR_COOKIES, clear_cookies) - hass.services.async_register("kiosker", SERVICE_CLEAR_CACHE, clear_cache) - hass.services.async_register( - "kiosker", SERVICE_SCREENSAVER_INTERACT, screensaver_interact + hass.services.async_register(DOMAIN, SERVICE_BLACKOUT_SET, blackout_set) + hass.services.async_register(DOMAIN, SERVICE_BLACKOUT_CLEAR, blackout_clear) + + +type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] + + +def convert_rgb_to_hex(color: str | list[int]) -> str: + """Convert RGB color to hex format.""" + if isinstance(color, str): + # If already a string, assume it's hex or named color + if color.startswith("#"): + return color + # Handle named colors or other formats + return color + if isinstance(color, list) and len(color) == 3: + # Convert RGB list [r, g, b] to hex format + r, g, b = [int(x) for x in color] + return f"#{r:02x}{g:02x}{b:02x}" + # Fallback to default if conversion fails + return "#000000" + + +async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: + """Set up Kiosker from a config entry.""" + if KioskerAPI is None: + raise ConfigEntryNotReady("Kiosker dependency not available") + + api = KioskerAPI( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + token=entry.data[CONF_API_TOKEN], + ssl=entry.data.get(CONF_SSL, False), + verify=entry.data.get(CONF_SSL_VERIFY, False), ) - hass.services.async_register("kiosker", SERVICE_BLACKOUT_SET, blackout_set) - hass.services.async_register("kiosker", SERVICE_BLACKOUT_CLEAR, blackout_clear) + + coordinator = KioskerDataUpdateCoordinator( + hass, + api, + entry.data[CONF_POLL_INTERVAL], + ) + + await coordinator.async_config_entry_first_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + # Register services globally (only once) + if not hass.services.has_service(DOMAIN, SERVICE_NAVIGATE_URL): + await _register_services(hass) return True async def async_unload_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: """Unload a config entry.""" - # Remove services - hass.services.async_remove("kiosker", SERVICE_NAVIGATE_URL) - hass.services.async_remove("kiosker", SERVICE_NAVIGATE_REFRESH) - hass.services.async_remove("kiosker", SERVICE_NAVIGATE_HOME) - hass.services.async_remove("kiosker", SERVICE_NAVIGATE_BACKWARD) - hass.services.async_remove("kiosker", SERVICE_NAVIGATE_FORWARD) - hass.services.async_remove("kiosker", SERVICE_PRINT) - hass.services.async_remove("kiosker", SERVICE_CLEAR_COOKIES) - hass.services.async_remove("kiosker", SERVICE_CLEAR_CACHE) - hass.services.async_remove("kiosker", SERVICE_SCREENSAVER_INTERACT) - hass.services.async_remove("kiosker", SERVICE_BLACKOUT_SET) - hass.services.async_remove("kiosker", SERVICE_BLACKOUT_CLEAR) - - return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + # Only remove services if this is the last kiosker entry + if len(hass.config_entries.async_entries(DOMAIN)) == 1: + hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_URL) + hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_REFRESH) + hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_HOME) + hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_BACKWARD) + hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_FORWARD) + hass.services.async_remove(DOMAIN, SERVICE_PRINT) + hass.services.async_remove(DOMAIN, SERVICE_CLEAR_COOKIES) + hass.services.async_remove(DOMAIN, SERVICE_CLEAR_CACHE) + hass.services.async_remove(DOMAIN, SERVICE_SCREENSAVER_INTERACT) + hass.services.async_remove(DOMAIN, SERVICE_BLACKOUT_SET) + hass.services.async_remove(DOMAIN, SERVICE_BLACKOUT_CLEAR) + + return unload_ok diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 76b8d347408bf..b2a3b3ced472f 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -20,22 +20,22 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: todo docs-configuration-parameters: todo docs-installation-parameters: todo entity-unavailable: todo - integration-owner: todo + integration-owner: done log-when-unavailable: todo parallel-updates: todo reauthentication-flow: todo test-coverage: todo # Gold - devices: todo + devices: done diagnostics: todo discovery-update-info: todo - discovery: todo + discovery: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/kiosker/services.yaml b/homeassistant/components/kiosker/services.yaml index 3f8e42be354c3..9b69f4da6f8ed 100644 --- a/homeassistant/components/kiosker/services.yaml +++ b/homeassistant/components/kiosker/services.yaml @@ -1,4 +1,7 @@ navigate_url: + target: + device: + integration: kiosker fields: url: required: true @@ -6,31 +9,54 @@ navigate_url: text: navigate_refresh: + target: + device: + integration: kiosker navigate_home: + target: + device: + integration: kiosker navigate_backward: + target: + device: + integration: kiosker navigate_forward: + target: + device: + integration: kiosker print: + target: + device: + integration: kiosker clear_cookies: + target: + device: + integration: kiosker clear_cache: + target: + device: + integration: kiosker screensaver_interact: - -blackout_clear: + target: + device: + integration: kiosker blackout_set: + target: + device: + integration: kiosker fields: visible: - default: true selector: boolean: text: - default: "Hello, World!" selector: text: background: @@ -42,6 +68,12 @@ blackout_set: icon: selector: text: + expire: + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds dismissible: selector: boolean: @@ -55,12 +87,12 @@ blackout_set: selector: text: sound: - selector: - text: - expire: - default: 60 selector: number: min: 0 - max: 3600 - unit_of_measurement: seconds + max: 9999 + +blackout_clear: + target: + device: + integration: kiosker diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 15aa4d7f6fa9d..da7244fd3607f 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -190,6 +190,10 @@ "description": "Sound to play when blackout is displayed (SystemSoundID, e.g., 1007)" } } + }, + "blackout_clear": { + "name": "Clear blackout", + "description": "Clear the blackout screen" } } } diff --git a/tests/components/kiosker/__init__.py b/tests/components/kiosker/__init__.py new file mode 100644 index 0000000000000..2e998256f154c --- /dev/null +++ b/tests/components/kiosker/__init__.py @@ -0,0 +1 @@ +"""Tests for the Kiosker integration.""" diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py new file mode 100644 index 0000000000000..ed9f9443aa527 --- /dev/null +++ b/tests/components/kiosker/test_config_flow.py @@ -0,0 +1,362 @@ +"""Test the Kiosker config flow.""" + +from unittest.mock import Mock, patch + +from homeassistant import config_entries +from homeassistant.components.kiosker.config_flow import CannotConnect +from homeassistant.components.kiosker.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from tests.common import MockConfigEntry + +DISCOVERY_INFO = ZeroconfServiceInfo( + ip="192.168.1.39", + port=8081, + hostname="kiosker-device.local.", + type="_kiosker._tcp.local.", + name="Kiosker Device._kiosker._tcp.local.", + properties={ + "uuid": "12345678-1234-1234-1234-123456789abc", + "app": "Kiosker", + "version": "1.0.0", + }, +) + +DISCOVERY_INFO_NO_UUID = ZeroconfServiceInfo( + ip="192.168.1.39", + port=8081, + hostname="kiosker-device.local.", + type="_kiosker._tcp.local.", + name="Kiosker Device._kiosker._tcp.local.", + properties={"app": "Kiosker", "version": "1.0.0"}, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with ( + patch( + "homeassistant.components.kiosker.config_flow.validate_input" + ) as mock_validate, + patch( + "homeassistant.components.kiosker.async_setup_entry", return_value=True + ) as mock_setup_entry, + patch("kiosker.KioskerAPI") as mock_api_class, + ): + mock_status = Mock() + mock_status.device_id = "test-device-123" + mock_api = Mock() + mock_api.status.return_value = mock_status + mock_api_class.return_value = mock_api + + mock_validate.return_value = {"title": "Kiosker test-device-123"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + CONF_PORT: 8081, + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Kiosker test-device-123" + assert result2["data"] == { + CONF_HOST: "192.168.1.100", + CONF_PORT: 8081, + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_host(hass: HomeAssistant) -> None: + """Test we handle invalid host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.kiosker.config_flow.validate_input", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + CONF_PORT: 8081, + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unexpected_exception(hass: HomeAssistant) -> None: + """Test we handle unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.kiosker.config_flow.validate_input", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + CONF_PORT: 8081, + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_zeroconf(hass: HomeAssistant) -> None: + """Test zeroconf discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + context = result["context"] + assert context["title_placeholders"] == { + "name": "Kiosker (12345678)", + "host": "192.168.1.39", + "port": "8081", + } + + +async def test_zeroconf_no_uuid(hass: HomeAssistant) -> None: + """Test zeroconf discovery without UUID.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO_NO_UUID, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + context = result["context"] + assert context["title_placeholders"] == { + "name": "Kiosker 192.168.1.39", + "host": "192.168.1.39", + "port": "8081", + } + + +async def test_zeroconf_confirm(hass: HomeAssistant) -> None: + """Test zeroconf confirmation step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "discovery_confirm" + + +async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: + """Test zeroconf discovery confirmation with token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + with ( + patch( + "homeassistant.components.kiosker.config_flow.validate_input" + ) as mock_validate, + patch( + "homeassistant.components.kiosker.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + mock_validate.return_value = {"title": "Kiosker Device"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Kiosker Device" + assert result3["data"] == { + CONF_HOST: "192.168.1.39", + CONF_PORT: 8081, + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> None: + """Test zeroconf discovery confirmation with connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + with patch( + "homeassistant.components.kiosker.config_flow.validate_input", + side_effect=CannotConnect, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + }, + ) + + assert result3["type"] is FlowResultType.FORM + assert result3["errors"] == {"base": "cannot_connect"} + + +async def test_abort_if_already_configured(hass: HomeAssistant) -> None: + """Test we abort if already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081}, + unique_id="test-device-123", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.kiosker.config_flow.validate_input" + ) as mock_validate, + patch("kiosker.KioskerAPI") as mock_api_class, + ): + mock_status = Mock() + mock_status.device_id = "test-device-123" + mock_api = Mock() + mock_api.status.return_value = mock_status + mock_api_class.return_value = mock_api + + mock_validate.return_value = {"title": "Kiosker test-device-123"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.200", + CONF_PORT: 8081, + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_zeroconf_abort_if_already_configured(hass: HomeAssistant) -> None: + """Test we abort zeroconf discovery if already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081}, + unique_id="12345678-1234-1234-1234-123456789abc", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None: + """Test manual setup falls back to host:port when device_id unavailable.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.kiosker.config_flow.validate_input" + ) as mock_validate, + patch("homeassistant.components.kiosker.async_setup_entry", return_value=True), + patch("kiosker.KioskerAPI") as mock_api_class, + ): + # Mock API that fails to get status + mock_api = Mock() + mock_api.status.side_effect = Exception("Connection failed") + mock_api_class.return_value = mock_api + + mock_validate.return_value = {"title": "Kiosker 192.168.1.100"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + CONF_PORT: 8081, + "api_token": "test-token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Kiosker 192.168.1.100" From 7f8ee6dfb38579227582e7287ea953642d24b45f Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 23 Jul 2025 23:50:38 +0200 Subject: [PATCH 05/69] Kiosker --- homeassistant/components/kiosker/__init__.py | 33 ++++++++++++++++++- .../components/kiosker/coordinator.py | 3 ++ .../components/kiosker/quality_scale.yaml | 12 +++---- homeassistant/components/kiosker/sensor.py | 2 ++ homeassistant/components/kiosker/switch.py | 2 ++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 0783526f23834..482a3a22ff833 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from kiosker import Blackout, KioskerAPI from homeassistant.config_entries import ConfigEntry @@ -44,6 +46,10 @@ from .coordinator import KioskerDataUpdateCoordinator _PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] +_LOGGER = logging.getLogger(__name__) + +# Limit concurrent updates to prevent overwhelming the API +PARALLEL_UPDATES = 3 async def _get_target_coordinators( @@ -84,6 +90,29 @@ async def _get_target_coordinators( return coordinators +async def _call_api_safe( + hass: HomeAssistant, + coordinator: KioskerDataUpdateCoordinator, + api_method, + action_name: str, + *args, +) -> None: + """Call API method with error handling and logging.""" + try: + await hass.async_add_executor_job(api_method, *args) + except (OSError, TimeoutError) as exc: + _LOGGER.error( + "Failed to %s on device %s: %s", action_name, coordinator.api.host, exc + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.debug( + "Unexpected error during %s on device %s: %s", + action_name, + coordinator.api.host, + exc, + ) + + async def _register_services(hass: HomeAssistant) -> None: """Register Kiosker services.""" @@ -93,7 +122,9 @@ async def navigate_url(call: ServiceCall) -> None: coordinators = await _get_target_coordinators(hass, call) for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.navigate_url, url) + await _call_api_safe( + hass, coordinator, coordinator.api.navigate_url, "navigate to URL", url + ) async def navigate_refresh(call: ServiceCall) -> None: """Refresh page service.""" diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index 541162dfbec3c..99ec4e9bccfe5 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -42,6 +42,9 @@ async def _async_update_data(self) -> dict: self.api.screensaver_get_state ) except Exception as exception: + _LOGGER.warning( + "Failed to update Kiosker data: %s", exception, exc_info=True + ) raise UpdateFailed(exception) from exception else: return { diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index b2a3b3ced472f..41bfffa26bc8c 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -21,13 +21,13 @@ rules: # Silver action-exceptions: done - config-entry-unloading: todo - docs-configuration-parameters: todo - docs-installation-parameters: todo - entity-unavailable: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done integration-owner: done - log-when-unavailable: todo - parallel-updates: todo + log-when-unavailable: done + parallel-updates: done reauthentication-flow: todo test-coverage: todo diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index d8ead9593368b..d669f3a732384 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -22,6 +22,8 @@ from .coordinator import KioskerDataUpdateCoordinator from .entity import KioskerEntity +PARALLEL_UPDATES = 3 + @dataclass(frozen=True) class KioskerSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index f5eabd586aa11..7c4ca3730fc05 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -12,6 +12,8 @@ from .coordinator import KioskerDataUpdateCoordinator from .entity import KioskerEntity +PARALLEL_UPDATES = 3 + async def async_setup_entry( hass: HomeAssistant, From a6752b21d9f5fd93599fe30b17ddbf893ae35913 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Tue, 29 Jul 2025 23:36:56 +0200 Subject: [PATCH 06/69] Kiosker --- .claude/settings.local.json | 12 +- .../components/kiosker/config_flow.py | 49 +++++++ .../components/kiosker/coordinator.py | 18 +++ .../components/kiosker/quality_scale.yaml | 2 +- homeassistant/components/kiosker/strings.json | 17 ++- homeassistant/components/kiosker/versions.py | 127 ++++++++++++++++++ 6 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/kiosker/versions.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 159a20cc47d50..d48d6c5ed077e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,6 +1,14 @@ { "permissions": { - "allow": ["mcp__ide__getDiagnostics", "Bash(rg:*)", "Bash(find:*)"], + "allow": [ + "mcp__ide__getDiagnostics", + "Bash(rg:*)", + "Bash(find:*)", + "WebFetch(domain:developers.home-assistant.io)", + "WebFetch(domain:pypi.org)", + "WebFetch(domain:docs.kiosker.io)", + "Bash(python:*)" + ], "deny": [] } -} +} \ No newline at end of file diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index b6fa998ff3c34..b51308820fd82 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -234,6 +235,54 @@ async def async_step_discovery_confirm( errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + ) + + # Get the config entry that's being re-authenticated + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + if entry is None: + return self.async_abort(reason="reauth_failed") + + # Create new config data with updated token + new_data = entry.data.copy() + new_data[CONF_API_TOKEN] = user_input[CONF_API_TOKEN] + + # Validate the new token + try: + await validate_input(self.hass, new_data) + except CannotConnect: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors={"base": "invalid_auth"}, + ) + except Exception: + _LOGGER.exception("Unexpected exception during reauth") + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors={"base": "unknown"}, + ) + + # Update the config entry with new token + self.hass.config_entries.async_update_entry(entry, data=new_data) + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index 99ec4e9bccfe5..93a83ec4f82b4 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -8,6 +8,7 @@ from kiosker import KioskerAPI from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -42,6 +43,11 @@ async def _async_update_data(self) -> dict: self.api.screensaver_get_state ) except Exception as exception: + # Check if this is an authentication error (401) + if self._is_auth_error(exception): + _LOGGER.warning("Authentication failed for Kiosker: %s", exception) + raise ConfigEntryAuthFailed("Authentication failed") from exception + _LOGGER.warning( "Failed to update Kiosker data: %s", exception, exc_info=True ) @@ -52,3 +58,15 @@ async def _async_update_data(self) -> dict: "blackout": blackout, "screensaver": screensaver, } + + def _is_auth_error(self, exception: Exception) -> bool: + """Check if exception indicates authentication failure.""" + error_str = str(exception).lower() + # Check for common HTTP 401/authentication error patterns + return ( + "401" in error_str + or "unauthorized" in error_str + or "authentication" in error_str + or "invalid token" in error_str + or "access denied" in error_str + ) diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 41bfffa26bc8c..c69c0a2cfe046 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -28,7 +28,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index da7244fd3607f..4df9cbcca20ae 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -44,15 +44,28 @@ "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." } + }, + "reauth_confirm": { + "title": "Reauthenticate Kiosker", + "description": "Authentication failed. This may be caused by an invalid API token or changed IP filtering settings. Please update the API token, or the IP filter in the Kiosker App.", + "data": { + "api_token": "API Token" + }, + "data_description": { + "api_token": "Enter the new API token for the Kiosker App. This can be generated in the app API settings." + } } }, "error": { - "cannot_connect": "Failed to connect to Kiosker device", + "cannot_connect": "Failed to connect to Kiosker device.", + "invalid_auth": "Authentication failed.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "Reauthentication was successful", + "reauth_failed": "Reauthentication failed" } }, "entity": { diff --git a/homeassistant/components/kiosker/versions.py b/homeassistant/components/kiosker/versions.py new file mode 100644 index 0000000000000..856532c4645f6 --- /dev/null +++ b/homeassistant/components/kiosker/versions.py @@ -0,0 +1,127 @@ +"""Version-based feature availability for Kiosker integration.""" + +from __future__ import annotations + +import logging + +from packaging import version + +_LOGGER = logging.getLogger(__name__) + +# Supported feature categories +FEATURE_CATEGORIES = ("sensors", "switches", "services") + + +def _process_added_features(available: dict[str, list[str]], features: dict) -> None: + """Process added features for a version.""" + if "added" not in features: + return + + for category in FEATURE_CATEGORIES: + if category in features["added"]: + for feature in features["added"][category]: + if feature not in available[category]: + available[category].append(feature) + + +def _process_removed_features( + available: dict[str, list[str]], features: dict, ver_str: str +) -> None: + """Process removed features for a version.""" + if "removed" not in features: + return + + for category in FEATURE_CATEGORIES: + if category in features["removed"]: + for feature in features["removed"][category]: + if feature in available[category]: + available[category].remove(feature) + else: + _LOGGER.warning( + "Attempted to remove non-existent %s feature '%s' in version %s", + category, + feature, + ver_str, + ) + + +# Version-based feature availability +VERSION_FEATURES = { + "2025.9.1": { # Initial supported version + "added": { + "sensors": [ + "batteryLevel", + "batteryState", + "lastInteraction", + "lastMotion", + "lastUpdate", + "blackoutState", + "screensaverVisibility", + ], + "switches": ["disable_screensaver"], + "services": [ + "navigate_url", + "navigate_refresh", + "navigate_home", + "navigate_backward", + "navigate_forward", + "print", + "clear_cookies", + "clear_cache", + "screensaver_interact", + "blackout_set", + "blackout_clear", + ], + } + }, + "2026.1.1": {"removed": {"sensors": ["batteryState"]}}, +} + + +def get_available_features(app_version: str) -> dict[str, list[str]]: + """Get features available for given app version with fallback.""" + if not app_version or not app_version.strip(): + _LOGGER.error("Invalid app version provided: %s", app_version) + return {category: [] for category in FEATURE_CATEGORIES} + + try: + app_ver = version.parse(app_version.strip()) + except (ValueError, TypeError) as exc: + _LOGGER.error("Failed to parse app version '%s': %s", app_version, exc) + return {category: [] for category in FEATURE_CATEGORIES} + + # Initialize available features + available: dict[str, list[str]] = {category: [] for category in FEATURE_CATEGORIES} + + # Sort version keys to process in chronological order using semantic versioning + sorted_versions = sorted(VERSION_FEATURES.keys(), key=version.parse) + + # Process all versions <= app_version + for ver_str in sorted_versions: + if app_ver >= version.parse(ver_str): + features = VERSION_FEATURES[ver_str] + _process_added_features(available, features) + _process_removed_features(available, features, ver_str) + else: + break # Stop when we hit a version higher than app + + return available + + +def is_version_supported(app_version: str) -> bool: + """Check if app version meets minimum requirements.""" + if not app_version or not app_version.strip(): + return False + + try: + app_ver = version.parse(app_version.strip()) + min_version = min(VERSION_FEATURES.keys(), key=version.parse) + return app_ver >= version.parse(min_version) + except (ValueError, TypeError) as exc: + _LOGGER.error("Failed to validate version '%s': %s", app_version, exc) + return False + + +def get_minimum_version() -> str: + """Get the minimum supported app version.""" + return min(VERSION_FEATURES.keys(), key=version.parse) From b188faa472f9e6fd27b0e05cf8b0690c951e1865 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 2 Aug 2025 00:18:16 +0200 Subject: [PATCH 07/69] Kiosker --- homeassistant/components/kiosker/__init__.py | 8 +- .../components/kiosker/services.yaml | 7 +- homeassistant/components/kiosker/versions.py | 127 --------------- tests/components/kiosker/conftest.py | 74 +++++++++ tests/components/kiosker/test_config_flow.py | 153 +++++++++++++++--- 5 files changed, 216 insertions(+), 153 deletions(-) delete mode 100644 homeassistant/components/kiosker/versions.py create mode 100644 tests/components/kiosker/conftest.py diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 482a3a22ff833..7076828b18ab2 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -197,11 +197,11 @@ async def blackout_set(call: ServiceCall) -> None: background=background_color, foreground=foreground_color, icon=call.data.get(ATTR_ICON, ""), - expire=call.data.get(ATTR_EXPIRE, 0), + expire=call.data.get(ATTR_EXPIRE, 60), dismissible=call.data.get(ATTR_DISMISSIBLE, False), - button_background=button_background_color, - button_foreground=button_foreground_color, - button_text=call.data.get(ATTR_BUTTON_TEXT, "Dismiss"), + buttonBackground=button_background_color, + buttonForeground=button_foreground_color, + buttonText=call.data.get(ATTR_BUTTON_TEXT, None), sound=call.data.get(ATTR_SOUND, 0), ) diff --git a/homeassistant/components/kiosker/services.yaml b/homeassistant/components/kiosker/services.yaml index 9b69f4da6f8ed..14cf69ff15b2a 100644 --- a/homeassistant/components/kiosker/services.yaml +++ b/homeassistant/components/kiosker/services.yaml @@ -54,9 +54,11 @@ blackout_set: integration: kiosker fields: visible: + default: true selector: boolean: text: + default: "Hello, World!" selector: text: background: @@ -69,6 +71,7 @@ blackout_set: selector: text: expire: + default: 60 selector: number: min: 0 @@ -88,9 +91,7 @@ blackout_set: text: sound: selector: - number: - min: 0 - max: 9999 + text: blackout_clear: target: diff --git a/homeassistant/components/kiosker/versions.py b/homeassistant/components/kiosker/versions.py deleted file mode 100644 index 856532c4645f6..0000000000000 --- a/homeassistant/components/kiosker/versions.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Version-based feature availability for Kiosker integration.""" - -from __future__ import annotations - -import logging - -from packaging import version - -_LOGGER = logging.getLogger(__name__) - -# Supported feature categories -FEATURE_CATEGORIES = ("sensors", "switches", "services") - - -def _process_added_features(available: dict[str, list[str]], features: dict) -> None: - """Process added features for a version.""" - if "added" not in features: - return - - for category in FEATURE_CATEGORIES: - if category in features["added"]: - for feature in features["added"][category]: - if feature not in available[category]: - available[category].append(feature) - - -def _process_removed_features( - available: dict[str, list[str]], features: dict, ver_str: str -) -> None: - """Process removed features for a version.""" - if "removed" not in features: - return - - for category in FEATURE_CATEGORIES: - if category in features["removed"]: - for feature in features["removed"][category]: - if feature in available[category]: - available[category].remove(feature) - else: - _LOGGER.warning( - "Attempted to remove non-existent %s feature '%s' in version %s", - category, - feature, - ver_str, - ) - - -# Version-based feature availability -VERSION_FEATURES = { - "2025.9.1": { # Initial supported version - "added": { - "sensors": [ - "batteryLevel", - "batteryState", - "lastInteraction", - "lastMotion", - "lastUpdate", - "blackoutState", - "screensaverVisibility", - ], - "switches": ["disable_screensaver"], - "services": [ - "navigate_url", - "navigate_refresh", - "navigate_home", - "navigate_backward", - "navigate_forward", - "print", - "clear_cookies", - "clear_cache", - "screensaver_interact", - "blackout_set", - "blackout_clear", - ], - } - }, - "2026.1.1": {"removed": {"sensors": ["batteryState"]}}, -} - - -def get_available_features(app_version: str) -> dict[str, list[str]]: - """Get features available for given app version with fallback.""" - if not app_version or not app_version.strip(): - _LOGGER.error("Invalid app version provided: %s", app_version) - return {category: [] for category in FEATURE_CATEGORIES} - - try: - app_ver = version.parse(app_version.strip()) - except (ValueError, TypeError) as exc: - _LOGGER.error("Failed to parse app version '%s': %s", app_version, exc) - return {category: [] for category in FEATURE_CATEGORIES} - - # Initialize available features - available: dict[str, list[str]] = {category: [] for category in FEATURE_CATEGORIES} - - # Sort version keys to process in chronological order using semantic versioning - sorted_versions = sorted(VERSION_FEATURES.keys(), key=version.parse) - - # Process all versions <= app_version - for ver_str in sorted_versions: - if app_ver >= version.parse(ver_str): - features = VERSION_FEATURES[ver_str] - _process_added_features(available, features) - _process_removed_features(available, features, ver_str) - else: - break # Stop when we hit a version higher than app - - return available - - -def is_version_supported(app_version: str) -> bool: - """Check if app version meets minimum requirements.""" - if not app_version or not app_version.strip(): - return False - - try: - app_ver = version.parse(app_version.strip()) - min_version = min(VERSION_FEATURES.keys(), key=version.parse) - return app_ver >= version.parse(min_version) - except (ValueError, TypeError) as exc: - _LOGGER.error("Failed to validate version '%s': %s", app_version, exc) - return False - - -def get_minimum_version() -> str: - """Get the minimum supported app version.""" - return min(VERSION_FEATURES.keys(), key=version.parse) diff --git a/tests/components/kiosker/conftest.py b/tests/components/kiosker/conftest.py new file mode 100644 index 0000000000000..5da219ebd1a65 --- /dev/null +++ b/tests/components/kiosker/conftest.py @@ -0,0 +1,74 @@ +"""Common fixtures for the Kiosker tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.kiosker.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.kiosker.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Kiosker Device", + domain=DOMAIN, + data={ + CONF_HOST: "10.0.1.5", + CONF_PORT: 8081, + "api_token": "test_token", + "ssl": False, + "ssl_verify": False, + "poll_interval": 30, + }, + unique_id="A98BE1CE", + ) + + +@pytest.fixture +def mock_kiosker_api(): + """Mock KioskerAPI.""" + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.port = 8081 + + # Mock status data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + + return mock_api + + +@pytest.fixture +def mock_kiosker_api_class(): + """Mock the KioskerAPI class.""" + with patch( + "homeassistant.components.kiosker.config_flow.KioskerAPI" + ) as mock_api_class: + yield mock_api_class diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index ed9f9443aa527..c8403c62c58f5 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -1,9 +1,12 @@ """Test the Kiosker config flow.""" +from ipaddress import ip_address from unittest.mock import Mock, patch +import pytest + from homeassistant import config_entries -from homeassistant.components.kiosker.config_flow import CannotConnect +from homeassistant.components.kiosker.config_flow import CannotConnect, validate_input from homeassistant.components.kiosker.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -13,25 +16,27 @@ from tests.common import MockConfigEntry DISCOVERY_INFO = ZeroconfServiceInfo( - ip="192.168.1.39", - port=8081, + ip_address=ip_address("192.168.1.39"), + ip_addresses=[ip_address("192.168.1.39")], hostname="kiosker-device.local.", - type="_kiosker._tcp.local.", name="Kiosker Device._kiosker._tcp.local.", + port=8081, properties={ - "uuid": "12345678-1234-1234-1234-123456789abc", + "uuid": "A98BE1CE-1234-1234-1234-123456789ABC", "app": "Kiosker", "version": "1.0.0", }, + type="_kiosker._tcp.local.", ) DISCOVERY_INFO_NO_UUID = ZeroconfServiceInfo( - ip="192.168.1.39", - port=8081, + ip_address=ip_address("192.168.1.39"), + ip_addresses=[ip_address("192.168.1.39")], hostname="kiosker-device.local.", - type="_kiosker._tcp.local.", name="Kiosker Device._kiosker._tcp.local.", + port=8081, properties={"app": "Kiosker", "version": "1.0.0"}, + type="_kiosker._tcp.local.", ) @@ -50,7 +55,9 @@ async def test_form(hass: HomeAssistant) -> None: patch( "homeassistant.components.kiosker.async_setup_entry", return_value=True ) as mock_setup_entry, - patch("kiosker.KioskerAPI") as mock_api_class, + patch( + "homeassistant.components.kiosker.config_flow.KioskerAPI" + ) as mock_api_class, ): mock_status = Mock() mock_status.device_id = "test-device-123" @@ -147,9 +154,9 @@ async def test_zeroconf(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "zeroconf_confirm" - context = result["context"] - assert context["title_placeholders"] == { - "name": "Kiosker (12345678)", + # Check description placeholders instead of context + assert result["description_placeholders"] == { + "name": "Kiosker (A98BE1CE)", "host": "192.168.1.39", "port": "8081", } @@ -164,8 +171,8 @@ async def test_zeroconf_no_uuid(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "zeroconf_confirm" - context = result["context"] - assert context["title_placeholders"] == { + # Check description placeholders instead of context + assert result["description_placeholders"] == { "name": "Kiosker 192.168.1.39", "host": "192.168.1.39", "port": "8081", @@ -267,7 +274,7 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: """Test we abort if already configured.""" entry = MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081}, + data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "test_token"}, unique_id="test-device-123", ) entry.add_to_hass(hass) @@ -280,7 +287,9 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: patch( "homeassistant.components.kiosker.config_flow.validate_input" ) as mock_validate, - patch("kiosker.KioskerAPI") as mock_api_class, + patch( + "homeassistant.components.kiosker.config_flow.KioskerAPI" + ) as mock_api_class, ): mock_status = Mock() mock_status.device_id = "test-device-123" @@ -310,8 +319,8 @@ async def test_zeroconf_abort_if_already_configured(hass: HomeAssistant) -> None """Test we abort zeroconf discovery if already configured.""" entry = MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081}, - unique_id="12345678-1234-1234-1234-123456789abc", + data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "test_token"}, + unique_id="A98BE1CE-1234-1234-1234-123456789ABC", ) entry.add_to_hass(hass) @@ -336,7 +345,9 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None "homeassistant.components.kiosker.config_flow.validate_input" ) as mock_validate, patch("homeassistant.components.kiosker.async_setup_entry", return_value=True), - patch("kiosker.KioskerAPI") as mock_api_class, + patch( + "homeassistant.components.kiosker.config_flow.KioskerAPI" + ) as mock_api_class, ): # Mock API that fails to get status mock_api = Mock() @@ -360,3 +371,107 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Kiosker 192.168.1.100" + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "old_token"}, + unique_id="test-device-123", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.kiosker.config_flow.validate_input" + ) as mock_validate: + mock_validate.return_value = {"title": "Kiosker test-device-123"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_token": "new_token"}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data["api_token"] == "new_token" + + +async def test_reauth_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test reauth flow with connection error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "old_token"}, + unique_id="test-device-123", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + + with patch( + "homeassistant.components.kiosker.config_flow.validate_input", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_token": "invalid_token"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_validate_input_success( + hass: HomeAssistant, + mock_kiosker_api: Mock, + mock_kiosker_api_class: Mock, +) -> None: + """Test validate_input with successful connection.""" + + mock_kiosker_api_class.return_value = mock_kiosker_api + + data = { + CONF_HOST: "10.0.1.5", + CONF_PORT: 8081, + "api_token": "test_token", + "ssl": False, + "ssl_verify": False, + } + + result = await validate_input(hass, data) + assert result == {"title": "Kiosker A98BE1CE"} + + +async def test_validate_input_connection_error( + hass: HomeAssistant, + mock_kiosker_api: Mock, + mock_kiosker_api_class: Mock, +) -> None: + """Test validate_input with connection error.""" + + mock_kiosker_api.status.side_effect = Exception("Connection failed") + mock_kiosker_api_class.return_value = mock_kiosker_api + + data = { + CONF_HOST: "192.168.1.100", + CONF_PORT: 8081, + "api_token": "test_token", + "ssl": False, + "ssl_verify": False, + } + + with pytest.raises(CannotConnect): + await validate_input(hass, data) From 8f844a5061e43ced2273b7ccd5001b80df38d6a4 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 2 Aug 2025 01:04:50 +0200 Subject: [PATCH 08/69] Kiosker --- homeassistant/components/kiosker/__init__.py | 9 ++------- homeassistant/components/kiosker/manifest.json | 2 +- homeassistant/components/kiosker/quality_scale.yaml | 2 +- requirements_test_all.txt | 3 +++ 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 7076828b18ab2..01345d08cf507 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -62,14 +62,9 @@ async def _get_target_coordinators( referenced = async_extract_referenced_entity_ids(hass, call, expand_group=True) target_device_ids = referenced.referenced_devices - # If no targets specified, target all kiosker devices + # If no targets specified, fail the action if not target_device_ids: - coordinators.extend( - config_entry.runtime_data - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.runtime_data - ) - return coordinators + raise ValueError("No target devices specified for Kiosker service call") # Get device registry device_registry = dr.async_get(hass) diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index 8512ac603081b..bf0a35a7aff53 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kiosker", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["kiosker-python-api==1.2.5"], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index c69c0a2cfe046..1cebdb6819ad5 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -29,7 +29,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b11137956448..bfd91b4eca966 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1207,6 +1207,9 @@ justnimbus==0.7.4 # homeassistant.components.kegtron kegtron-ble==1.0.2 +# homeassistant.components.kiosker +kiosker-python-api==1.2.5 + # homeassistant.components.knocki knocki==0.4.2 From 1b04abaa690c960cc2cf69fa57c224bd9849c5af Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 9 Aug 2025 23:04:27 +0200 Subject: [PATCH 09/69] Kiosker --- homeassistant/components/kiosker/__init__.py | 336 +++++-- .../components/kiosker/config_flow.py | 51 +- .../components/kiosker/coordinator.py | 8 +- homeassistant/components/kiosker/entity.py | 48 +- homeassistant/components/kiosker/strings.json | 16 +- homeassistant/components/kiosker/switch.py | 2 +- tests/components/kiosker/__init__.py | 12 + tests/components/kiosker/conftest.py | 4 +- tests/components/kiosker/test_config_flow.py | 33 +- tests/components/kiosker/test_init.py | 203 ++++ tests/components/kiosker/test_sensor.py | 781 +++++++++++++++ tests/components/kiosker/test_services.py | 897 ++++++++++++++++++ tests/components/kiosker/test_switch.py | 395 ++++++++ 13 files changed, 2613 insertions(+), 173 deletions(-) create mode 100644 tests/components/kiosker/test_init.py create mode 100644 tests/components/kiosker/test_sensor.py create mode 100644 tests/components/kiosker/test_services.py create mode 100644 tests/components/kiosker/test_switch.py diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 01345d08cf507..d265053926b57 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -2,14 +2,17 @@ from __future__ import annotations +import asyncio +from collections.abc import Callable import logging +from urllib.parse import urlparse from kiosker import Blackout, KioskerAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service import async_extract_referenced_entity_ids @@ -47,6 +50,7 @@ _PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) +_SERVICE_REGISTRATION_LOCK = asyncio.Lock() # Limit concurrent updates to prevent overwhelming the API PARALLEL_UPDATES = 3 @@ -64,22 +68,28 @@ async def _get_target_coordinators( # If no targets specified, fail the action if not target_device_ids: - raise ValueError("No target devices specified for Kiosker service call") + raise ServiceValidationError( + "No target devices specified for Kiosker service call" + ) # Get device registry device_registry = dr.async_get(hass) + # Create a mapping of config entry ID to coordinator for better performance + entry_to_coordinator = { + entry.entry_id: entry.runtime_data + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.runtime_data + } + # Find coordinators for target devices for device_id in target_device_ids: device = device_registry.async_get(device_id) if device: - # Find the config entry for this device - for config_entry in hass.config_entries.async_entries(DOMAIN): - if ( - config_entry.runtime_data - and config_entry.entry_id in device.config_entries - ): - coordinators.append(config_entry.runtime_data) + # Find the coordinator for this device using direct lookup + for entry_id in device.config_entries: + if entry_id in entry_to_coordinator: + coordinators.append(entry_to_coordinator[entry_id]) break return coordinators @@ -88,7 +98,7 @@ async def _get_target_coordinators( async def _call_api_safe( hass: HomeAssistant, coordinator: KioskerDataUpdateCoordinator, - api_method, + api_method: Callable, action_name: str, *args, ) -> None: @@ -99,13 +109,174 @@ async def _call_api_safe( _LOGGER.error( "Failed to %s on device %s: %s", action_name, coordinator.api.host, exc ) - except Exception as exc: # noqa: BLE001 - _LOGGER.debug( - "Unexpected error during %s on device %s: %s", + except (ValueError, TypeError) as exc: + _LOGGER.error( + "Invalid parameters for %s on device %s: %s", action_name, coordinator.api.host, exc, ) + except Exception: + _LOGGER.exception( + "Unexpected error during %s on device %s", + action_name, + coordinator.api.host, + ) + + +async def _navigate_url_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Navigate to URL service.""" + url = call.data[ATTR_URL] + + # Validate URL format (allow any scheme including custom ones like "kiosker:") + try: + parsed_url = urlparse(url) + if not parsed_url.scheme: + raise ServiceValidationError( + f"Invalid URL format: {url}. URL must include a scheme" + ) + # For schemes other than http/https, we only validate basic structure + if parsed_url.scheme in ("http", "https") and not parsed_url.netloc: + raise ServiceValidationError( + f"Invalid URL format: {url}. HTTP/HTTPS URLs must include domain" + ) + except (ValueError, TypeError) as exc: + raise ServiceValidationError(f"Failed to parse URL {url}: {exc}") from exc + + coordinators = await _get_target_coordinators(hass, call) + + for coordinator in coordinators: + await _call_api_safe( + hass, coordinator, coordinator.api.navigate_url, "navigate to URL", url + ) + + +async def _navigate_refresh_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Refresh page service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe( + hass, coordinator, coordinator.api.navigate_refresh, "navigate refresh" + ) + + +async def _navigate_home_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Navigate home service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe( + hass, coordinator, coordinator.api.navigate_home, "navigate home" + ) + + +async def _navigate_backward_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Navigate backward service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe( + hass, + coordinator, + coordinator.api.navigate_backward, + "navigate backward", + ) + + +async def _navigate_forward_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Navigate forward service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe( + hass, coordinator, coordinator.api.navigate_forward, "navigate forward" + ) + + +async def _print_page_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Print page service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe(hass, coordinator, coordinator.api.print, "print page") + + +async def _clear_cookies_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Clear cookies service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe( + hass, coordinator, coordinator.api.clear_cookies, "clear cookies" + ) + + +async def _clear_cache_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Clear cache service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe( + hass, coordinator, coordinator.api.clear_cache, "clear cache" + ) + + +async def _screensaver_interact_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Interact with screensaver service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe( + hass, + coordinator, + coordinator.api.screensaver_interact, + "screensaver interact", + ) + + +async def _blackout_set_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Set blackout service.""" + if Blackout is None: + return + + coordinators = await _get_target_coordinators(hass, call) + + # Convert RGB values to hex format + background_color = convert_rgb_to_hex(call.data.get(ATTR_BACKGROUND, "#000000")) + foreground_color = convert_rgb_to_hex(call.data.get(ATTR_FOREGROUND, "#FFFFFF")) + button_background_color = convert_rgb_to_hex( + call.data.get(ATTR_BUTTON_BACKGROUND, "#FFFFFF") + ) + button_foreground_color = convert_rgb_to_hex( + call.data.get(ATTR_BUTTON_FOREGROUND, "#000000") + ) + + blackout = Blackout( + visible=call.data.get(ATTR_VISIBLE, True), + text=call.data.get(ATTR_TEXT, ""), + background=background_color, + foreground=foreground_color, + icon=call.data.get(ATTR_ICON, ""), + expire=call.data.get(ATTR_EXPIRE, 60), + dismissible=call.data.get(ATTR_DISMISSIBLE, False), + buttonBackground=button_background_color, + buttonForeground=button_foreground_color, + buttonText=call.data.get(ATTR_BUTTON_TEXT, None), + sound=call.data.get(ATTR_SOUND, 0), + ) + + for coordinator in coordinators: + await _call_api_safe( + hass, + coordinator, + coordinator.api.blackout_set, + "blackout set", + blackout, + ) + await coordinator.async_request_refresh() + + +async def _blackout_clear_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Clear blackout service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await _call_api_safe( + hass, coordinator, coordinator.api.blackout_clear, "blackout clear" + ) + await coordinator.async_request_refresh() async def _register_services(hass: HomeAssistant) -> None: @@ -113,103 +284,47 @@ async def _register_services(hass: HomeAssistant) -> None: async def navigate_url(call: ServiceCall) -> None: """Navigate to URL service.""" - url = call.data[ATTR_URL] - coordinators = await _get_target_coordinators(hass, call) - - for coordinator in coordinators: - await _call_api_safe( - hass, coordinator, coordinator.api.navigate_url, "navigate to URL", url - ) + await _navigate_url_handler(hass, call) async def navigate_refresh(call: ServiceCall) -> None: """Refresh page service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.navigate_refresh) + await _navigate_refresh_handler(hass, call) async def navigate_home(call: ServiceCall) -> None: """Navigate home service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.navigate_home) + await _navigate_home_handler(hass, call) async def navigate_backward(call: ServiceCall) -> None: """Navigate backward service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.navigate_backward) + await _navigate_backward_handler(hass, call) async def navigate_forward(call: ServiceCall) -> None: """Navigate forward service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.navigate_forward) + await _navigate_forward_handler(hass, call) async def print_page(call: ServiceCall) -> None: """Print page service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.print) + await _print_page_handler(hass, call) async def clear_cookies(call: ServiceCall) -> None: """Clear cookies service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.clear_cookies) + await _clear_cookies_handler(hass, call) async def clear_cache(call: ServiceCall) -> None: """Clear cache service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.clear_cache) + await _clear_cache_handler(hass, call) async def screensaver_interact(call: ServiceCall) -> None: """Interact with screensaver service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.screensaver_interact) + await _screensaver_interact_handler(hass, call) async def blackout_set(call: ServiceCall) -> None: """Set blackout service.""" - if Blackout is None: - return - - coordinators = await _get_target_coordinators(hass, call) - - # Convert RGB values to hex format - background_color = convert_rgb_to_hex(call.data.get(ATTR_BACKGROUND, "#000000")) - foreground_color = convert_rgb_to_hex(call.data.get(ATTR_FOREGROUND, "#FFFFFF")) - button_background_color = convert_rgb_to_hex( - call.data.get(ATTR_BUTTON_BACKGROUND, "#FFFFFF") - ) - button_foreground_color = convert_rgb_to_hex( - call.data.get(ATTR_BUTTON_FOREGROUND, "#000000") - ) - - blackout = Blackout( - visible=call.data.get(ATTR_VISIBLE, True), - text=call.data.get(ATTR_TEXT, ""), - background=background_color, - foreground=foreground_color, - icon=call.data.get(ATTR_ICON, ""), - expire=call.data.get(ATTR_EXPIRE, 60), - dismissible=call.data.get(ATTR_DISMISSIBLE, False), - buttonBackground=button_background_color, - buttonForeground=button_foreground_color, - buttonText=call.data.get(ATTR_BUTTON_TEXT, None), - sound=call.data.get(ATTR_SOUND, 0), - ) - - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.blackout_set, blackout) - await coordinator.async_request_refresh() + await _blackout_set_handler(hass, call) async def blackout_clear(call: ServiceCall) -> None: """Clear blackout service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await hass.async_add_executor_job(coordinator.api.blackout_clear) - await coordinator.async_request_refresh() + await _blackout_clear_handler(hass, call) # Register services hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_URL, navigate_url) @@ -239,10 +354,20 @@ def convert_rgb_to_hex(color: str | list[int]) -> str: # Handle named colors or other formats return color if isinstance(color, list) and len(color) == 3: - # Convert RGB list [r, g, b] to hex format - r, g, b = [int(x) for x in color] - return f"#{r:02x}{g:02x}{b:02x}" + try: + # Convert RGB list [r, g, b] to hex format with bounds checking + r, g, b = [max(0, min(255, int(x))) for x in color] + except (ValueError, TypeError) as exc: + _LOGGER.warning( + "Invalid RGB color values %s: %s. Using default color", color, exc + ) + return "#000000" + else: + return f"#{r:02x}{g:02x}{b:02x}" # Fallback to default if conversion fails + _LOGGER.warning( + "Invalid color format %s. Expected string or list of 3 integers", color + ) return "#000000" @@ -274,29 +399,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) - # Register services globally (only once) - if not hass.services.has_service(DOMAIN, SERVICE_NAVIGATE_URL): - await _register_services(hass) + # Register services globally (only once) - use lock to prevent race conditions + async with _SERVICE_REGISTRATION_LOCK: + if not hass.services.has_service(DOMAIN, SERVICE_NAVIGATE_URL): + await _register_services(hass) return True +def _remove_service_safe(hass: HomeAssistant, domain: str, service: str) -> None: + """Safely remove a service if it exists.""" + if hass.services.has_service(domain, service): + hass.services.async_remove(domain, service) + _LOGGER.debug("Removed service %s.%s", domain, service) + else: + _LOGGER.debug("Service %s.%s does not exist, skipping removal", domain, service) + + async def async_unload_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) # Only remove services if this is the last kiosker entry if len(hass.config_entries.async_entries(DOMAIN)) == 1: - hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_URL) - hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_REFRESH) - hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_HOME) - hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_BACKWARD) - hass.services.async_remove(DOMAIN, SERVICE_NAVIGATE_FORWARD) - hass.services.async_remove(DOMAIN, SERVICE_PRINT) - hass.services.async_remove(DOMAIN, SERVICE_CLEAR_COOKIES) - hass.services.async_remove(DOMAIN, SERVICE_CLEAR_CACHE) - hass.services.async_remove(DOMAIN, SERVICE_SCREENSAVER_INTERACT) - hass.services.async_remove(DOMAIN, SERVICE_BLACKOUT_SET) - hass.services.async_remove(DOMAIN, SERVICE_BLACKOUT_CLEAR) + # List of all services to remove + services_to_remove = [ + SERVICE_NAVIGATE_URL, + SERVICE_NAVIGATE_REFRESH, + SERVICE_NAVIGATE_HOME, + SERVICE_NAVIGATE_BACKWARD, + SERVICE_NAVIGATE_FORWARD, + SERVICE_PRINT, + SERVICE_CLEAR_COOKIES, + SERVICE_CLEAR_CACHE, + SERVICE_SCREENSAVER_INTERACT, + SERVICE_BLACKOUT_SET, + SERVICE_BLACKOUT_CLEAR, + ] + + # Remove each service safely + for service in services_to_remove: + _remove_service_safe(hass, DOMAIN, service) return unload_ok diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index b51308820fd82..619156082a385 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -57,13 +57,18 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: # Test connection by getting status status = await hass.async_add_executor_job(api.status) - except Exception as exc: + except (OSError, TimeoutError) as exc: _LOGGER.error("Failed to connect to Kiosker: %s", exc) raise CannotConnect from exc + except Exception as exc: + _LOGGER.error("Unexpected error connecting to Kiosker: %s", exc) + raise CannotConnect from exc # Return info that you want to store in the config entry device_id = status.device_id if hasattr(status, "device_id") else data[CONF_HOST] - return {"title": f"Kiosker {device_id}"} + # Use first 8 characters of device_id for consistency with entity naming + display_id = device_id[:8] if len(device_id) > 8 else device_id + return {"title": f"Kiosker {display_id}"} class ConfigFlow(HAConfigFlow, domain=DOMAIN): @@ -91,8 +96,11 @@ async def async_step_user( info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" + except (ValueError, TypeError) as exc: + _LOGGER.error("Invalid configuration data: %s", exc) + errors["base"] = "invalid_host" except Exception: - _LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected exception during validation") errors["base"] = "unknown" else: # Get device info to determine unique ID @@ -110,7 +118,11 @@ async def async_step_user( if hasattr(status, "device_id") else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" ) + except (OSError, TimeoutError, AttributeError) as exc: + _LOGGER.debug("Could not get device ID from status: %s", exc) + device_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" except Exception: # noqa: BLE001 + _LOGGER.debug("Unexpected error getting device ID from status") device_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" # Use device ID as unique identifier @@ -176,23 +188,10 @@ async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle zeroconf confirmation.""" - if user_input is not None: - # User confirmed, proceed to get API token - return await self.async_step_discovery_confirm() - - # Show confirmation form with the stored title placeholders - return self.async_show_form( - step_id="zeroconf_confirm", - description_placeholders=self.context["title_placeholders"], - ) - - async def async_step_discovery_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm discovery.""" errors: dict[str, str] = {} - if user_input is not None: - # Use stored discovery info + + if user_input is not None and CONF_API_TOKEN in user_input: + # Use stored discovery info and user-provided token host = self._discovered_host port = self._discovered_port @@ -211,7 +210,10 @@ async def async_step_discovery_confirm( try: info = await validate_input(self.hass, config_data) except CannotConnect: - errors["base"] = "cannot_connect" + errors[CONF_API_TOKEN] = "cannot_connect" + except (ValueError, TypeError) as exc: + _LOGGER.error("Invalid discovery data: %s", exc) + errors[CONF_API_TOKEN] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception during discovery validation") errors["base"] = "unknown" @@ -229,7 +231,7 @@ async def async_step_discovery_confirm( ) return self.async_show_form( - step_id="discovery_confirm", + step_id="zeroconf_confirm", data_schema=discovery_schema, description_placeholders=self.context["title_placeholders"], errors=errors, @@ -269,6 +271,13 @@ async def async_step_reauth_confirm( data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), errors={"base": "invalid_auth"}, ) + except (ValueError, TypeError) as exc: + _LOGGER.error("Invalid reauth data: %s", exc) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors={"base": "invalid_auth"}, + ) except Exception: _LOGGER.exception("Unexpected exception during reauth") return self.async_show_form( diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index 93a83ec4f82b4..0267072de8ca5 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import Any from kiosker import KioskerAPI @@ -34,7 +35,7 @@ def __init__( update_interval=timedelta(seconds=poll_interval), ) - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" try: status = await self.hass.async_add_executor_job(self.api.status) @@ -42,6 +43,11 @@ async def _async_update_data(self) -> dict: screensaver = await self.hass.async_add_executor_job( self.api.screensaver_get_state ) + except (OSError, TimeoutError) as exception: + _LOGGER.warning( + "Connection failed for Kiosker: %s", exception, exc_info=True + ) + raise UpdateFailed(exception) from exception except Exception as exception: # Check if this is an authentication error (401) if self._is_auth_error(exception): diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 699aa975e8556..cb8647f45a256 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -36,52 +36,32 @@ def __init__(self, coordinator: KioskerDataUpdateCoordinator) -> None: serial_number=device_id, ) - def _get_device_id(self) -> str: - """Get device ID from coordinator data.""" + def _get_status_attribute(self, attribute: str, default: str = "Unknown") -> str: + """Get attribute from coordinator status data.""" if ( self.coordinator.data and "status" in self.coordinator.data - and hasattr(self.coordinator.data["status"], "device_id") + and hasattr(self.coordinator.data["status"], attribute) ): - return self.coordinator.data["status"].device_id - return "unknown" + return getattr(self.coordinator.data["status"], attribute) + return default + + def _get_device_id(self) -> str: + """Get device ID from coordinator data.""" + return self._get_status_attribute("device_id", "unknown") def _get_app_name(self) -> str: """Get app name from coordinator data.""" - if ( - self.coordinator.data - and "status" in self.coordinator.data - and hasattr(self.coordinator.data["status"], "app_name") - ): - return self.coordinator.data["status"].app_name - return "Unknown" + return self._get_status_attribute("app_name") def _get_model(self) -> str: """Get model from coordinator data.""" - if ( - self.coordinator.data - and "status" in self.coordinator.data - and hasattr(self.coordinator.data["status"], "model") - ): - return self.coordinator.data["status"].model - return "Unknown" + return self._get_status_attribute("model") def _get_sw_version(self) -> str: """Get software version from coordinator data.""" - if ( - self.coordinator.data - and "status" in self.coordinator.data - and hasattr(self.coordinator.data["status"], "app_version") - ): - return self.coordinator.data["status"].app_version - return "Unknown" + return self._get_status_attribute("app_version") def _get_hw_version(self) -> str: - """Get software version from coordinator data.""" - if ( - self.coordinator.data - and "status" in self.coordinator.data - and hasattr(self.coordinator.data["status"], "os_version") - ): - return self.coordinator.data["status"].os_version - return "Unknown" + """Get hardware version from coordinator data.""" + return self._get_status_attribute("os_version") diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 4df9cbcca20ae..3dbc5e2f208fe 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -26,8 +26,20 @@ }, "zeroconf_confirm": { "title": "Discovered Kiosker App", - "description": "You are about to pair `{name}` at `{host}:{port}` with Home Assistant.\n\n**Would you like to proceed?**", - "submit": "Pair" + "description": "You are about to pair `{name}` at `{host}:{port}` with Home Assistant.\n\nPlease provide the API token to complete setup.", + "submit": "Pair", + "data": { + "api_token": "API Token", + "poll_interval": "Poll Interval (seconds)", + "ssl": "Use SSL", + "ssl_verify": "Verify certificate" + }, + "data_description": { + "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", + "poll_interval": "The interval in seconds to poll the Kiosker App for updates.", + "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", + "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." + } }, "discovery_confirm": { "title": "Pair Kiosker App", diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index 7c4ca3730fc05..fe00426330efd 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -28,7 +28,7 @@ async def async_setup_entry( class KioskerScreensaverSwitch(KioskerEntity, SwitchEntity): """Screensaver disable switch for Kiosker.""" - _has_entity_name = True + _attr_has_entity_name = True _attr_translation_key = "disable_screensaver" def __init__(self, coordinator: KioskerDataUpdateCoordinator) -> None: diff --git a/tests/components/kiosker/__init__.py b/tests/components/kiosker/__init__.py index 2e998256f154c..c8802126d50a5 100644 --- a/tests/components/kiosker/__init__.py +++ b/tests/components/kiosker/__init__.py @@ -1 +1,13 @@ """Tests for the Kiosker 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/kiosker/conftest.py b/tests/components/kiosker/conftest.py index 5da219ebd1a65..c952b7192e48a 100644 --- a/tests/components/kiosker/conftest.py +++ b/tests/components/kiosker/conftest.py @@ -36,7 +36,7 @@ def mock_config_entry() -> MockConfigEntry: "ssl_verify": False, "poll_interval": 30, }, - unique_id="A98BE1CE", + unique_id="A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", ) @@ -49,7 +49,7 @@ def mock_kiosker_api(): # Mock status data mock_status = MagicMock() - mock_status.device_id = "A98BE1CE" + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" mock_status.model = "iPad Pro" mock_status.os_version = "18.0" mock_status.app_name = "Kiosker" diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index c8403c62c58f5..830085c5fc3f0 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -60,12 +60,12 @@ async def test_form(hass: HomeAssistant) -> None: ) as mock_api_class, ): mock_status = Mock() - mock_status.device_id = "test-device-123" + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" mock_api = Mock() mock_api.status.return_value = mock_status mock_api_class.return_value = mock_api - mock_validate.return_value = {"title": "Kiosker test-device-123"} + mock_validate.return_value = {"title": "Kiosker A98BE1CE"} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -81,7 +81,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Kiosker test-device-123" + assert result2["title"] == "Kiosker A98BE1CE" assert result2["data"] == { CONF_HOST: "192.168.1.100", CONF_PORT: 8081, @@ -180,18 +180,21 @@ async def test_zeroconf_no_uuid(hass: HomeAssistant) -> None: async def test_zeroconf_confirm(hass: HomeAssistant) -> None: - """Test zeroconf confirmation step.""" + """Test zeroconf confirmation step shows form for API token.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DISCOVERY_INFO, ) - result2 = await hass.config_entries.flow.async_configure( + result_confirm = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "discovery_confirm" + assert result_confirm["type"] is FlowResultType.FORM + assert result_confirm["step_id"] == "zeroconf_confirm" + # Check that the form includes API token field + schema_keys = list(result_confirm["data_schema"].schema.keys()) + assert any(key.schema == "api_token" for key in schema_keys) async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: @@ -202,7 +205,7 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, ) - result2 = await hass.config_entries.flow.async_configure( + _result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -217,7 +220,7 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: mock_validate.return_value = {"title": "Kiosker Device"} result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { "api_token": "test-token", "ssl": False, @@ -248,7 +251,7 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> data=DISCOVERY_INFO, ) - result2 = await hass.config_entries.flow.async_configure( + _result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -257,7 +260,7 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> side_effect=CannotConnect, ): result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { "api_token": "test-token", "ssl": False, @@ -267,7 +270,7 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> ) assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": "cannot_connect"} + assert result3["errors"] == {"api_token": "cannot_connect"} async def test_abort_if_already_configured(hass: HomeAssistant) -> None: @@ -275,7 +278,7 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "test_token"}, - unique_id="test-device-123", + unique_id="A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", ) entry.add_to_hass(hass) @@ -292,12 +295,12 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: ) as mock_api_class, ): mock_status = Mock() - mock_status.device_id = "test-device-123" + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" mock_api = Mock() mock_api.status.return_value = mock_status mock_api_class.return_value = mock_api - mock_validate.return_value = {"title": "Kiosker test-device-123"} + mock_validate.return_value = {"title": "Kiosker A98BE1CE"} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/kiosker/test_init.py b/tests/components/kiosker/test_init.py new file mode 100644 index 0000000000000..0d77f556140a8 --- /dev/null +++ b/tests/components/kiosker/test_init.py @@ -0,0 +1,203 @@ +"""Test the Kiosker integration initialization.""" + +from unittest.mock import MagicMock, patch + +from homeassistant.components.kiosker.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Test unload + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_entry_failure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test an unsuccessful setup entry.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API that fails + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data", + side_effect=Exception("Connection failed"), + ): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_device_info( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + + await setup_integration(hass, mock_config_entry) + + # Check device was registered correctly + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC")} + ) + assert device_entry is not None + assert device_entry.name == "Kiosker A98BE1CE" + assert device_entry.manufacturer == "Top North" + assert device_entry.model == "Kiosker" + assert device_entry.sw_version == "25.1.1" + assert device_entry.hw_version == "iPad Pro (18.0)" + assert device_entry.serial_number == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + + +async def test_device_identifiers_and_info( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device identifiers and device info are set correctly.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data with specific device info + mock_status = MagicMock() + mock_status.device_id = "TEST_DEVICE_123" + mock_status.model = "iPad Mini" + mock_status.os_version = "17.5" + mock_status.app_name = "Kiosker" + mock_status.app_version = "24.1.0" + + mock_api.status.return_value = mock_status + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + + await setup_integration(hass, mock_config_entry) + + # Check device was registered with correct info + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "TEST_DEVICE_123")} + ) + assert device_entry is not None + assert device_entry.name == "Kiosker TEST_DEV" + assert device_entry.manufacturer == "Top North" + assert device_entry.model == "Kiosker" + assert device_entry.sw_version == "24.1.0" + assert device_entry.hw_version == "iPad Mini (17.5)" + assert device_entry.serial_number == "TEST_DEVICE_123" + assert device_entry.identifiers == {(DOMAIN, "TEST_DEVICE_123")} + + +async def test_service_registration_and_unregistration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that services are registered during setup and unregistered during unload.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + + # Before setup - services should not exist + assert not hass.services.has_service(DOMAIN, "navigate_url") + assert not hass.services.has_service(DOMAIN, "blackout_set") + + # Setup integration + await setup_integration(hass, mock_config_entry) + + # After setup - services should exist + assert hass.services.has_service(DOMAIN, "navigate_url") + assert hass.services.has_service(DOMAIN, "navigate_refresh") + assert hass.services.has_service(DOMAIN, "navigate_home") + assert hass.services.has_service(DOMAIN, "navigate_backward") + assert hass.services.has_service(DOMAIN, "navigate_forward") + assert hass.services.has_service(DOMAIN, "print") + assert hass.services.has_service(DOMAIN, "clear_cookies") + assert hass.services.has_service(DOMAIN, "clear_cache") + assert hass.services.has_service(DOMAIN, "screensaver_interact") + assert hass.services.has_service(DOMAIN, "blackout_set") + assert hass.services.has_service(DOMAIN, "blackout_clear") + + # Unload integration + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # After unload - services should be removed (if this was the last entry) + assert not hass.services.has_service(DOMAIN, "navigate_url") + assert not hass.services.has_service(DOMAIN, "blackout_set") diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py new file mode 100644 index 0000000000000..27f72d2191ac1 --- /dev/null +++ b/tests/components/kiosker/test_sensor.py @@ -0,0 +1,781 @@ +"""Test the Kiosker sensors.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock, patch + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_sensors_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setting up all sensors.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_screensaver = MagicMock() + mock_screensaver.visible = True + + mock_blackout = MagicMock() + mock_blackout.visible = True + mock_blackout.text = "Test blackout" + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + mock_api.blackout_get.return_value = mock_blackout + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + "blackout": mock_blackout, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check that all sensor entities were created + expected_sensors = [ + "sensor.kiosker_a98be1ce_battery_level", + "sensor.kiosker_a98be1ce_battery_state", + "sensor.kiosker_a98be1ce_last_interaction", + "sensor.kiosker_a98be1ce_last_motion", + "sensor.kiosker_a98be1ce_last_update", + "sensor.kiosker_a98be1ce_blackout_state", + "sensor.kiosker_a98be1ce_screensaver_visibility", + ] + + for sensor_id in expected_sensors: + state = hass.states.get(sensor_id) + assert state is not None, f"Sensor {sensor_id} was not created" + + # Check entity registry + entity_registry = er.async_get(hass) + for sensor_id in expected_sensors: + entity = entity_registry.async_get(sensor_id) + assert entity is not None, f"Sensor {sensor_id} not in entity registry" + + +async def test_battery_level_sensor( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test battery level sensor.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 42 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check battery level sensor + state = hass.states.get("sensor.kiosker_a98be1ce_battery_level") + assert state is not None + assert state.state == "42" + assert state.attributes["unit_of_measurement"] == "%" + assert state.attributes["device_class"] == "battery" + assert state.attributes["state_class"] == "measurement" + + +async def test_battery_state_sensor( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test battery state sensor.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "discharging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check battery state sensor + state = hass.states.get("sensor.kiosker_a98be1ce_battery_state") + assert state is not None + assert state.state == "discharging" + assert state.attributes["icon"] == "mdi:lightning-bolt" + + +async def test_last_interaction_sensor( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test last interaction sensor.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check last interaction sensor + state = hass.states.get("sensor.kiosker_a98be1ce_last_interaction") + assert state is not None + assert state.state == "2025-01-01T12:00:00+00:00" + assert state.attributes["device_class"] == "timestamp" + assert state.attributes["icon"] == "mdi:gesture-tap" + + +async def test_last_motion_sensor( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test last motion sensor.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check last motion sensor + state = hass.states.get("sensor.kiosker_a98be1ce_last_motion") + assert state is not None + assert state.state == "2025-01-01T11:55:00+00:00" + assert state.attributes["device_class"] == "timestamp" + assert state.attributes["icon"] == "mdi:motion-sensor" + + +async def test_last_update_sensor( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test last update sensor.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check last update sensor + state = hass.states.get("sensor.kiosker_a98be1ce_last_update") + assert state is not None + assert state.state == "2025-01-01T12:05:00+00:00" + assert state.attributes["device_class"] == "timestamp" + assert state.attributes["icon"] == "mdi:update" + + +async def test_blackout_state_sensor_active( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test blackout state sensor when blackout is active.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + # Setup blackout data + mock_blackout = MagicMock() + mock_blackout.visible = True + mock_blackout.text = "Test blackout message" + mock_blackout.background = "#000000" + mock_blackout.foreground = "#FFFFFF" + + mock_api.status.return_value = mock_status + mock_api.blackout_get.return_value = mock_blackout + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + "blackout": mock_blackout, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + "blackout": mock_blackout, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check blackout state sensor + state = hass.states.get("sensor.kiosker_a98be1ce_blackout_state") + assert state is not None + assert state.state == "active" + assert state.attributes["icon"] == "mdi:monitor-off" + # Check that blackout data is in extra attributes + assert "visible" in state.attributes + assert "text" in state.attributes + assert "background" in state.attributes + assert "foreground" in state.attributes + + +async def test_blackout_state_sensor_inactive( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test blackout state sensor when blackout is inactive.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + mock_api.blackout_get.return_value = None + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration with no blackout data + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + # No blackout key + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + # No blackout key + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check blackout state sensor + state = hass.states.get("sensor.kiosker_a98be1ce_blackout_state") + assert state is not None + assert state.state == "inactive" + assert state.attributes["icon"] == "mdi:monitor-off" + + +async def test_screensaver_visibility_sensor_visible( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test screensaver visibility sensor when screensaver is visible.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + # Setup screensaver data + mock_screensaver = MagicMock() + mock_screensaver.visible = True + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + "screensaver": mock_screensaver, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check screensaver visibility sensor + state = hass.states.get("sensor.kiosker_a98be1ce_screensaver_visibility") + assert state is not None + assert state.state == "visible" + assert state.attributes["icon"] == "mdi:power-sleep" + + +async def test_screensaver_visibility_sensor_hidden( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test screensaver visibility sensor when screensaver is hidden.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + # Setup screensaver data + mock_screensaver = MagicMock() + mock_screensaver.visible = False + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + "screensaver": mock_screensaver, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check screensaver visibility sensor + state = hass.states.get("sensor.kiosker_a98be1ce_screensaver_visibility") + assert state is not None + assert state.state == "hidden" + assert state.attributes["icon"] == "mdi:power-sleep" + + +async def test_sensors_missing_data( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test sensors when data is missing.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data with missing attributes + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + # Explicitly remove attributes that should be missing + del mock_status.battery_level + del mock_status.battery_state + del mock_status.last_interaction + del mock_status.last_motion + del mock_status.last_update + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration with missing data + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + # No screensaver or blackout data + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + # No screensaver or blackout data + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check that sensors handle missing data gracefully + sensors_with_unknown_state = [ + "sensor.kiosker_a98be1ce_battery_level", + "sensor.kiosker_a98be1ce_battery_state", + "sensor.kiosker_a98be1ce_last_interaction", + "sensor.kiosker_a98be1ce_last_motion", + "sensor.kiosker_a98be1ce_last_update", + ] + + for sensor_id in sensors_with_unknown_state: + state = hass.states.get(sensor_id) + assert state is not None + assert state.state == "unknown" + + # Blackout sensor should be "inactive" when no data + blackout_state = hass.states.get("sensor.kiosker_a98be1ce_blackout_state") + assert blackout_state is not None + assert blackout_state.state == "inactive" + + # Screensaver sensor should be "hidden" when no data (this is the default) + screensaver_state = hass.states.get( + "sensor.kiosker_a98be1ce_screensaver_visibility" + ) + assert screensaver_state is not None + assert screensaver_state.state == "hidden" + + +async def test_sensor_unique_ids( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test sensor unique ID generation.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data with custom device ID + mock_status = MagicMock() + mock_status.device_id = "TEST_SENSOR_ID" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check that sensor entities have correct unique IDs + entity_registry = er.async_get(hass) + + expected_unique_ids = [ + ("sensor.kiosker_test_sen_battery_level", "TEST_SENSOR_ID_battery_level"), + ("sensor.kiosker_test_sen_battery_state", "TEST_SENSOR_ID_battery_state"), + ("sensor.kiosker_test_sen_last_interaction", "TEST_SENSOR_ID_last_interaction"), + ("sensor.kiosker_test_sen_last_motion", "TEST_SENSOR_ID_last_motion"), + ("sensor.kiosker_test_sen_last_update", "TEST_SENSOR_ID_last_update"), + ("sensor.kiosker_test_sen_blackout_state", "TEST_SENSOR_ID_blackout_state"), + ( + "sensor.kiosker_test_sen_screensaver_visibility", + "TEST_SENSOR_ID_screensaver_visibility", + ), + ] + + for entity_id, expected_unique_id in expected_unique_ids: + entity = entity_registry.async_get(entity_id) + assert entity is not None, f"Entity {entity_id} not found" + assert entity.unique_id == expected_unique_id + + +async def test_parse_datetime_function() -> None: + """Test the parse_datetime utility function.""" + # Import here to avoid circular imports during test discovery + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.kiosker.sensor import parse_datetime + + # Test with None + assert parse_datetime(None) is None + + # Test with datetime object + dt = datetime(2025, 1, 1, 12, 0, 0) + assert parse_datetime(dt) == dt + + # Test with ISO string + result = parse_datetime("2025-01-01T12:00:00Z") + assert result is not None + assert result.year == 2025 + assert result.month == 1 + assert result.day == 1 + assert result.hour == 12 + + # Test with invalid string + assert parse_datetime("invalid") is None + + # Test with non-string, non-datetime + assert parse_datetime(123) is None diff --git a/tests/components/kiosker/test_services.py b/tests/components/kiosker/test_services.py new file mode 100644 index 0000000000000..2812e1a0cf87d --- /dev/null +++ b/tests/components/kiosker/test_services.py @@ -0,0 +1,897 @@ +"""Test the Kiosker services.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.kiosker.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_navigate_url_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test navigate_url service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.navigate_url = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + assert len(devices) == 1 + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "navigate_url", + { + "url": "https://example.com", + }, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.navigate_url.assert_called_once_with("https://example.com") + + +async def test_navigate_refresh_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test navigate_refresh service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.navigate_refresh = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "navigate_refresh", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.navigate_refresh.assert_called_once() + + +async def test_navigate_home_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test navigate_home service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.navigate_home = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "navigate_home", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.navigate_home.assert_called_once() + + +async def test_navigate_backward_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test navigate_backward service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.navigate_backward = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "navigate_backward", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.navigate_backward.assert_called_once() + + +async def test_navigate_forward_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test navigate_forward service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.navigate_forward = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "navigate_forward", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.navigate_forward.assert_called_once() + + +async def test_print_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test print service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.print = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "print", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.print.assert_called_once() + + +async def test_clear_cookies_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test clear_cookies service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.clear_cookies = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "clear_cookies", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.clear_cookies.assert_called_once() + + +async def test_clear_cache_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test clear_cache service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.clear_cache = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "clear_cache", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.clear_cache.assert_called_once() + + +async def test_screensaver_interact_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test screensaver_interact service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.screensaver_interact = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Call the service + await hass.services.async_call( + DOMAIN, + "screensaver_interact", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify API was called + mock_api.screensaver_interact.assert_called_once() + + +async def test_blackout_set_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test blackout_set service with all parameters.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.blackout_set = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Mock the coordinator's async_request_refresh method + coordinator = mock_config_entry.runtime_data + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + # Call the service with all parameters + await hass.services.async_call( + DOMAIN, + "blackout_set", + { + "visible": True, + "text": "Test Message", + "background": [255, 0, 0], # RGB red + "foreground": "#00FF00", # Hex green + "icon": "alert", + "expire": 120, + "dismissible": True, + "button_background": [0, 0, 255], # RGB blue + "button_foreground": "#FFFF00", # Hex yellow + "button_text": "Dismiss", + "sound": 1, + }, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify coordinator refresh was called + mock_refresh.assert_called_once() + + # Verify API was called + mock_api.blackout_set.assert_called_once() + + # Check the blackout object passed to API + blackout_call = mock_api.blackout_set.call_args[0][0] + assert blackout_call.visible is True + assert blackout_call.text == "Test Message" + assert blackout_call.background == "#ff0000" # RGB converted to hex + assert blackout_call.foreground == "#00FF00" # Hex preserved + assert blackout_call.icon == "alert" + assert blackout_call.expire == 120 + assert blackout_call.dismissible is True + assert blackout_call.buttonBackground == "#0000ff" # RGB converted to hex + assert blackout_call.buttonForeground == "#FFFF00" # Hex preserved + assert blackout_call.buttonText == "Dismiss" + assert blackout_call.sound == 1 + + +async def test_blackout_set_service_defaults( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test blackout_set service with default values.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.blackout_set = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Mock the coordinator's async_request_refresh method + coordinator = mock_config_entry.runtime_data + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + # Call the service with minimal parameters (testing defaults) + await hass.services.async_call( + DOMAIN, + "blackout_set", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify coordinator refresh was called + mock_refresh.assert_called_once() + + # Verify API was called + mock_api.blackout_set.assert_called_once() + + # Check the blackout object with default values + blackout_call = mock_api.blackout_set.call_args[0][0] + assert blackout_call.visible is True # Default + assert blackout_call.text == "" # Default + assert blackout_call.background == "#000000" # Default + assert blackout_call.foreground == "#FFFFFF" # Default + assert blackout_call.icon == "" # Default + assert blackout_call.expire == 60 # Default + assert blackout_call.dismissible is False # Default + assert blackout_call.buttonBackground == "#FFFFFF" # Default + assert blackout_call.buttonForeground == "#000000" # Default + assert blackout_call.buttonText is None # Default + assert blackout_call.sound == 0 # Default + + +async def test_blackout_clear_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test blackout_clear service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.blackout_clear = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + device_id = devices[0].id + + # Mock the coordinator's async_request_refresh method + coordinator = mock_config_entry.runtime_data + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + # Call the service + await hass.services.async_call( + DOMAIN, + "blackout_clear", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify coordinator refresh was called + mock_refresh.assert_called_once() + + # Verify API was called + mock_api.blackout_clear.assert_called_once() + + +async def test_service_without_device_target_fails( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that services fail when no device is targeted.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Try to call service without target - should raise ServiceValidationError + with pytest.raises(ServiceValidationError, match="No target devices specified"): + await hass.services.async_call( + DOMAIN, + "navigate_refresh", + {}, + blocking=True, + ) + + +async def test_convert_rgb_to_hex_function() -> None: + """Test the convert_rgb_to_hex utility function.""" + # Import here to avoid circular imports during test discovery + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.kiosker import convert_rgb_to_hex + + # Test with hex string (should be preserved) + assert convert_rgb_to_hex("#FF0000") == "#FF0000" + assert convert_rgb_to_hex("#ff0000") == "#ff0000" + assert convert_rgb_to_hex("#123456") == "#123456" + + # Test with named color string (should be preserved) + assert convert_rgb_to_hex("red") == "red" + assert convert_rgb_to_hex("blue") == "blue" + assert convert_rgb_to_hex("transparent") == "transparent" + + # Test with valid RGB lists + assert convert_rgb_to_hex([255, 0, 0]) == "#ff0000" + assert convert_rgb_to_hex([0, 255, 0]) == "#00ff00" + assert convert_rgb_to_hex([0, 0, 255]) == "#0000ff" + assert convert_rgb_to_hex([128, 64, 192]) == "#8040c0" + assert convert_rgb_to_hex([0, 0, 0]) == "#000000" + assert convert_rgb_to_hex([255, 255, 255]) == "#ffffff" + + # Test bounds checking - values should be clamped to 0-255 range + assert convert_rgb_to_hex([300, 0, 0]) == "#ff0000" # 300 clamped to 255 + assert convert_rgb_to_hex([-10, 0, 0]) == "#000000" # -10 clamped to 0 + assert convert_rgb_to_hex([128, 300, -50]) == "#80ff00" # Mixed bounds + assert convert_rgb_to_hex([1000, -1000, 500]) == "#ff00ff" # Extreme values + + # Test type conversion within RGB lists + assert convert_rgb_to_hex([255.0, 128.9, 0.1]) == "#ff8000" # Float to int + assert convert_rgb_to_hex(["255", "128", "0"]) == "#ff8000" # String to int + + # Test invalid RGB value types (should fallback to default) + assert convert_rgb_to_hex([255, "invalid", 0]) == "#000000" + assert convert_rgb_to_hex([255, None, 0]) == "#000000" + assert convert_rgb_to_hex([255, [], 0]) == "#000000" + + # Test invalid list lengths (should return default) + assert convert_rgb_to_hex([255, 0]) == "#000000" # Too short + assert convert_rgb_to_hex([255, 0, 0, 128]) == "#000000" # Too long + assert convert_rgb_to_hex([]) == "#000000" # Empty list + + # Test invalid input types (should return default) + assert convert_rgb_to_hex(None) == "#000000" + assert convert_rgb_to_hex(123) == "#000000" + assert convert_rgb_to_hex(123.45) == "#000000" + assert convert_rgb_to_hex({}) == "#000000" + assert convert_rgb_to_hex(set()) == "#000000" + + # Test edge cases + assert convert_rgb_to_hex("") == "" # Empty string should be preserved + assert ( + convert_rgb_to_hex("not_hex_not_starting_with_hash") + == "not_hex_not_starting_with_hash" + ) + + +async def test_navigate_url_validation( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test URL validation in navigate_url service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.navigate_url = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + assert len(devices) == 1 + device_id = devices[0].id + + # Test valid HTTP URL + await hass.services.async_call( + DOMAIN, + "navigate_url", + {"url": "https://www.example.com", "device_id": device_id}, + blocking=True, + ) + mock_api.navigate_url.assert_called_with("https://www.example.com") + mock_api.navigate_url.reset_mock() + + # Test valid HTTPS URL with path and query + await hass.services.async_call( + DOMAIN, + "navigate_url", + {"url": "https://www.example.com/path?param=value", "device_id": device_id}, + blocking=True, + ) + mock_api.navigate_url.assert_called_with( + "https://www.example.com/path?param=value" + ) + mock_api.navigate_url.reset_mock() + + # Test valid custom scheme (like kiosker:) + await hass.services.async_call( + DOMAIN, + "navigate_url", + {"url": "kiosker://reload", "device_id": device_id}, + blocking=True, + ) + mock_api.navigate_url.assert_called_with("kiosker://reload") + mock_api.navigate_url.reset_mock() + + # Test other custom schemes + await hass.services.async_call( + DOMAIN, + "navigate_url", + {"url": "file:///path/to/file.html", "device_id": device_id}, + blocking=True, + ) + mock_api.navigate_url.assert_called_with("file:///path/to/file.html") + mock_api.navigate_url.reset_mock() + + # Test URL without scheme (should be rejected) + with pytest.raises( + ServiceValidationError, match="Invalid URL format.*must include a scheme" + ): + await hass.services.async_call( + DOMAIN, + "navigate_url", + {"url": "www.example.com", "device_id": device_id}, + blocking=True, + ) + # Should not call the API + mock_api.navigate_url.assert_not_called() + mock_api.navigate_url.reset_mock() + + # Test HTTP URL without domain (should be rejected) + with pytest.raises( + ServiceValidationError, match="Invalid URL format.*must include domain" + ): + await hass.services.async_call( + DOMAIN, + "navigate_url", + {"url": "http://", "device_id": device_id}, + blocking=True, + ) + # Should not call the API + mock_api.navigate_url.assert_not_called() + mock_api.navigate_url.reset_mock() + + # Test HTTPS URL without domain (should be rejected) + with pytest.raises( + ServiceValidationError, match="Invalid URL format.*must include domain" + ): + await hass.services.async_call( + DOMAIN, + "navigate_url", + {"url": "https://", "device_id": device_id}, + blocking=True, + ) + # Should not call the API + mock_api.navigate_url.assert_not_called() + mock_api.navigate_url.reset_mock() + + # Test malformed URL that would cause parsing exception + with pytest.raises( + ServiceValidationError, match="Failed to parse URL.*Invalid IPv6 URL" + ): + await hass.services.async_call( + DOMAIN, + "navigate_url", + {"url": "malformed://[invalid", "device_id": device_id}, + blocking=True, + ) + # Should not call the API + mock_api.navigate_url.assert_not_called() diff --git a/tests/components/kiosker/test_switch.py b/tests/components/kiosker/test_switch.py new file mode 100644 index 0000000000000..25be3dc048874 --- /dev/null +++ b/tests/components/kiosker/test_switch.py @@ -0,0 +1,395 @@ +"""Test the Kiosker switch.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_screensaver_switch_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setting up switch.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data that coordinator will return + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_screensaver = MagicMock() + mock_screensaver.disabled = False + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration with proper coordinator mocking + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check that the switch entity was created + state = hass.states.get("switch.kiosker_a98be1ce_disable_screensaver") + assert state is not None + + # Check entity registry + entity_registry = er.async_get(hass) + entity = entity_registry.async_get("switch.kiosker_a98be1ce_disable_screensaver") + assert entity is not None + assert ( + entity.unique_id == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC_disable_screensaver" + ) + + +async def test_screensaver_switch_is_on_true( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test switch is_on property when screensaver is disabled.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data - screensaver is disabled (switch is on) + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_screensaver = MagicMock() + mock_screensaver.disabled = True + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check that the switch is on (screensaver disabled) + state = hass.states.get("switch.kiosker_a98be1ce_disable_screensaver") + assert state is not None + assert state.state == "on" + + +async def test_screensaver_switch_is_on_false( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test switch is_on property when screensaver is enabled.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data - screensaver is enabled (switch is off) + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_screensaver = MagicMock() + mock_screensaver.disabled = False + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check that the switch is off (screensaver enabled) + state = hass.states.get("switch.kiosker_a98be1ce_disable_screensaver") + assert state is not None + assert state.state == "off" + + +async def test_screensaver_switch_is_on_none( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test switch is_on property when screensaver data is unavailable.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data with no screensaver data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = None + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration with no screensaver data + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + # No screensaver key + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check that the switch is unknown (no screensaver data) + state = hass.states.get("switch.kiosker_a98be1ce_disable_screensaver") + assert state is not None + assert state.state == "unknown" + + +async def test_screensaver_switch_turn_on( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test turning on the switch (disabling screensaver).""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.screensaver_set_disabled_state = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_screensaver = MagicMock() + mock_screensaver.disabled = False + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with ( + patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update, + patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator.async_request_refresh" + ) as mock_refresh, + ): + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + } + mock_refresh.return_value = None + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Turn on the switch + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.kiosker_a98be1ce_disable_screensaver"}, + blocking=True, + ) + + # Verify API was called to disable screensaver + mock_api.screensaver_set_disabled_state.assert_called_once_with(True) + mock_refresh.assert_called() + + +async def test_screensaver_switch_turn_off( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test turning off the switch (enabling screensaver).""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api.screensaver_set_disabled_state = MagicMock() + mock_api_class.return_value = mock_api + + # Setup mock data - initially disabled + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_screensaver = MagicMock() + mock_screensaver.disabled = True + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with ( + patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update, + patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator.async_request_refresh" + ) as mock_refresh, + ): + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + } + mock_refresh.return_value = None + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Turn off the switch + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.kiosker_a98be1ce_disable_screensaver"}, + blocking=True, + ) + + # Verify API was called to enable screensaver + mock_api.screensaver_set_disabled_state.assert_called_once_with(False) + mock_refresh.assert_called() + + +async def test_screensaver_switch_unique_id( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test switch unique ID generation.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data with custom device ID + mock_status = MagicMock() + mock_status.device_id = "TEST_DEVICE_ID" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + + mock_screensaver = MagicMock() + mock_screensaver.disabled = False + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + "screensaver": mock_screensaver, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check that the switch entity has the correct unique ID + entity_registry = er.async_get(hass) + entity = entity_registry.async_get("switch.kiosker_test_dev_disable_screensaver") + assert entity is not None + assert entity.unique_id == "TEST_DEVICE_ID_disable_screensaver" From 6a49e95e01ad3d9cdb53bc352bf968d34b4acce6 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 18 Aug 2025 23:16:11 +0200 Subject: [PATCH 10/69] Kiosker --- homeassistant/components/kiosker/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index d265053926b57..6622128699547 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -79,7 +79,7 @@ async def _get_target_coordinators( entry_to_coordinator = { entry.entry_id: entry.runtime_data for entry in hass.config_entries.async_entries(DOMAIN) - if entry.runtime_data + if hasattr(entry, "runtime_data") and entry.runtime_data } # Find coordinators for target devices From 9cf95a81ac36aa63b184a1701bf43e3094b348de Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 18 Aug 2025 23:35:58 +0200 Subject: [PATCH 11/69] Remove .claude folder from tracking --- .claude/settings.local.json | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index d48d6c5ed077e..0000000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__ide__getDiagnostics", - "Bash(rg:*)", - "Bash(find:*)", - "WebFetch(domain:developers.home-assistant.io)", - "WebFetch(domain:pypi.org)", - "WebFetch(domain:docs.kiosker.io)", - "Bash(python:*)" - ], - "deny": [] - } -} \ No newline at end of file From 57305428c3ec4e70203c76c3de45c7cef28e2cfd Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 18 Aug 2025 23:47:00 +0200 Subject: [PATCH 12/69] Updated claude.md --- homeassistant/components/kiosker/Claude.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/kiosker/Claude.md b/homeassistant/components/kiosker/Claude.md index 6d37bfc34e8b8..49d2b3f747b29 100644 --- a/homeassistant/components/kiosker/Claude.md +++ b/homeassistant/components/kiosker/Claude.md @@ -52,4 +52,5 @@ Use kiosker-python-api version 1.2.3 from PyPi - Use modern Python. - Look at "api.py", and "data.py" for library usage. - Main language is English. -- Confirm to quality_scale bronze. \ No newline at end of file +- Confirm to quality_scale silver. +- Generate tests. \ No newline at end of file From 80d7eaba37a55bfa7e00b050648119fb40ddbbd9 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Fri, 24 Oct 2025 22:13:17 +0200 Subject: [PATCH 13/69] bf rebase --- homeassistant/components/kiosker/Claude.md | 2 +- homeassistant/components/kiosker/__init__.py | 3 +- .../components/kiosker/config_flow.py | 7 - homeassistant/components/kiosker/const.py | 4 +- .../components/kiosker/coordinator.py | 8 +- .../components/kiosker/diagnostics.py | 238 ++++++++++++++++++ .../components/kiosker/manifest.json | 2 +- homeassistant/components/kiosker/sensor.py | 7 + homeassistant/components/kiosker/strings.json | 11 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/kiosker/conftest.py | 1 - tests/components/kiosker/test_config_flow.py | 9 - 13 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/kiosker/diagnostics.py diff --git a/homeassistant/components/kiosker/Claude.md b/homeassistant/components/kiosker/Claude.md index 49d2b3f747b29..345a130f32d0f 100644 --- a/homeassistant/components/kiosker/Claude.md +++ b/homeassistant/components/kiosker/Claude.md @@ -12,7 +12,7 @@ Use kiosker-python-api version 1.2.3 from PyPi # Set up flow - Let the user set up Kiosker visually. -- Let the user configure the connection by host, port (default 8081), API token, SSL (bool), and poll interval (default 30s). +- Let the user configure the connection by host, port (default 8081), API token, and SSL (bool). - Allow multiple Kiosker instances. # Switches diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 6622128699547..a166be10f133f 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -30,7 +30,6 @@ ATTR_URL, ATTR_VISIBLE, CONF_API_TOKEN, - CONF_POLL_INTERVAL, CONF_SSL, CONF_SSL_VERIFY, DOMAIN, @@ -387,7 +386,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> b coordinator = KioskerDataUpdateCoordinator( hass, api, - entry.data[CONF_POLL_INTERVAL], + entry, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 619156082a385..93a317af920c8 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -17,10 +17,8 @@ from .const import ( CONF_API_TOKEN, - CONF_POLL_INTERVAL, CONF_SSL, CONF_SSL_VERIFY, - DEFAULT_POLL_INTERVAL, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_SSL_VERIFY, @@ -34,7 +32,6 @@ vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_API_TOKEN): str, - vol.Optional(CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL): int, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, } @@ -200,9 +197,6 @@ async def async_step_zeroconf_confirm( CONF_HOST: host, CONF_PORT: port, CONF_API_TOKEN: user_input[CONF_API_TOKEN], - CONF_POLL_INTERVAL: user_input.get( - CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL - ), CONF_SSL: user_input.get(CONF_SSL, DEFAULT_SSL), CONF_SSL_VERIFY: user_input.get(CONF_SSL_VERIFY, DEFAULT_SSL_VERIFY), } @@ -224,7 +218,6 @@ async def async_step_zeroconf_confirm( discovery_schema = vol.Schema( { vol.Required(CONF_API_TOKEN): str, - vol.Optional(CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL): int, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, } diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py index ed3ad566829ce..40a8a755d3fbb 100644 --- a/homeassistant/components/kiosker/const.py +++ b/homeassistant/components/kiosker/const.py @@ -6,11 +6,9 @@ CONF_API_TOKEN = "api_token" CONF_SSL = "ssl" CONF_SSL_VERIFY = "ssl_verify" -CONF_POLL_INTERVAL = "poll_interval" - # Default values DEFAULT_PORT = 8081 -DEFAULT_POLL_INTERVAL = 30 +POLL_INTERVAL = 30 # Fixed polling interval in seconds DEFAULT_SSL = False DEFAULT_SSL_VERIFY = False diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index 0267072de8ca5..9d69519c17a2f 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -8,11 +8,12 @@ from kiosker import KioskerAPI +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DOMAIN, POLL_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -24,7 +25,7 @@ def __init__( self, hass: HomeAssistant, api: KioskerAPI, - poll_interval: int, + config_entry: ConfigEntry, ) -> None: """Initialize.""" self.api = api @@ -32,7 +33,8 @@ def __init__( hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=poll_interval), + update_interval=timedelta(seconds=POLL_INTERVAL), + config_entry=config_entry, ) async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/kiosker/diagnostics.py b/homeassistant/components/kiosker/diagnostics.py new file mode 100644 index 0000000000000..edefa70532304 --- /dev/null +++ b/homeassistant/components/kiosker/diagnostics.py @@ -0,0 +1,238 @@ +"""Diagnostics support for Kiosker integration. + +This module provides comprehensive diagnostic data collection for the Kiosker integration, +including device status, coordinator state, integration health, and service registration +status. Sensitive data like API tokens and device identifiers are automatically redacted +for privacy protection. + +Usage: +- Access via Settings → Devices & Services → Kiosker → Download Diagnostics +- The resulting JSON file contains troubleshooting information safe to share +""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from . import KioskerConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +# Comprehensive list of sensitive data fields to redact +TO_REDACT = { + "api_token", + "token", + "password", + "key", + "secret", + "auth", + "unique_id", + "device_id", + "serial_number", + "mac_address", + "macaddress", + "latitude", + "longitude", + "location", + "address", + "coordinates", + "wifi_ssid", + "network_name", + "ssid", + "username", + "email", +} + +# Device status fields to extract safely +DEVICE_STATUS_FIELDS = [ + "model", + "os_version", + "app_name", + "app_version", + "battery_level", + "battery_state", + "last_interaction", + "last_motion", + "last_update", + "ambient_light", +] + +# Kiosker service names for service registration check +KIOSKER_SERVICES = [ + "navigate_url", + "navigate_refresh", + "navigate_home", + "navigate_backward", + "navigate_forward", + "print", + "clear_cookies", + "clear_cache", + "screensaver_interact", + "blackout_set", + "blackout_clear", +] + + +def _safe_get_device_info(coordinator_data: dict[str, Any]) -> dict[str, Any]: + """Safely extract device information from coordinator data.""" + device_info = {} + + try: + if coordinator_data.get("status"): + status = coordinator_data["status"] + + # Use dictionary comprehension for cleaner field extraction + device_info = { + field: getattr(status, field, None) for field in DEVICE_STATUS_FIELDS + } + + except (AttributeError, KeyError, TypeError) as err: + _LOGGER.warning("Failed to extract device info for diagnostics: %s", err) + device_info = {"extraction_error": f"Failed to extract device info: {err}"} + + return device_info + + +def _safe_get_coordinator_data(coordinator) -> dict[str, Any]: + """Safely extract coordinator data with error handling.""" + try: + return coordinator.data or {} + except (AttributeError, TypeError) as err: + _LOGGER.warning("Failed to access coordinator data: %s", err) + return {"access_error": f"Failed to access coordinator data: {err}"} + + +def _get_coordinator_metadata(coordinator) -> dict[str, Any]: + """Extract coordinator metadata safely.""" + try: + # Sanitize exception message to avoid sensitive data leakage + last_exception = None + if coordinator.last_exception: + exception_msg = str(coordinator.last_exception) + # Remove potential sensitive info from exception messages + if any( + sensitive in exception_msg.lower() + for sensitive in ("token", "password", "key") + ): + last_exception = "Authentication or credential error (details hidden)" + else: + last_exception = exception_msg + + return { + "last_update_success": coordinator.last_update_success, + "last_exception": last_exception, + "update_interval_seconds": coordinator.update_interval.total_seconds(), + "name": coordinator.name, + "last_update_success_time": getattr( + coordinator, "last_update_success_time", None + ), + } + except (AttributeError, TypeError) as err: + _LOGGER.warning("Failed to extract coordinator metadata: %s", err) + return {"metadata_error": f"Failed to extract metadata: {err}"} + + +def _get_integration_info(entry: KioskerConfigEntry) -> dict[str, Any]: + """Get integration state and setup information.""" + try: + return { + "version": getattr(entry, "version", 1), + "minor_version": getattr(entry, "minor_version", 1), + "state": entry.state.value if entry.state else "unknown", + "title": entry.title, + "domain": entry.domain, + "setup_retry_count": getattr(entry, "_setup_retry_count", 0), + "supports_unload": entry.supports_unload, + "supports_remove_device": entry.supports_remove_device, + } + except (AttributeError, TypeError) as err: + _LOGGER.warning("Failed to extract integration info: %s", err) + return {"integration_error": f"Failed to extract integration info: {err}"} + + +def _get_service_info(hass: HomeAssistant) -> dict[str, Any]: + """Get service registration status.""" + try: + services_registered = [] + services_missing = [] + + for service in KIOSKER_SERVICES: + if hass.services.has_service(DOMAIN, service): + services_registered.append(service) + else: + services_missing.append(service) + + return { + "services_registered": services_registered, + "services_missing": services_missing, + "total_services_expected": len(KIOSKER_SERVICES), + "registration_complete": len(services_missing) == 0, + } + except (AttributeError, TypeError) as err: + _LOGGER.warning("Failed to extract service info: %s", err) + return {"service_error": f"Failed to extract service info: {err}"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: KioskerConfigEntry +) -> dict[str, Any]: + """Return comprehensive diagnostics for a Kiosker config entry. + + Collects device status, coordinator state, integration health, service + registration status, and configuration details while protecting sensitive + data through automatic redaction. + + Args: + hass: Home Assistant instance + entry: Kiosker configuration entry + + Returns: + Dictionary containing diagnostic data with sensitive fields redacted + """ + try: + coordinator = entry.runtime_data + except (AttributeError, TypeError) as err: + _LOGGER.error("Failed to access runtime data for diagnostics: %s", err) + return { + "error": "Failed to access integration runtime data", + "entry_basic_info": { + "title": entry.title, + "domain": entry.domain, + "state": entry.state.value if entry.state else "unknown", + }, + } + + # Safely collect all diagnostic data + coordinator_data = _safe_get_coordinator_data(coordinator) + device_info = _safe_get_device_info(coordinator_data) + coordinator_metadata = _get_coordinator_metadata(coordinator) + integration_info = _get_integration_info(entry) + service_info = _get_service_info(hass) + + # Build comprehensive diagnostics dictionary + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "entry_options": async_redact_data(entry.options, TO_REDACT), + "integration_info": integration_info, + "coordinator_data": async_redact_data(coordinator_data, TO_REDACT), + "coordinator_metadata": coordinator_metadata, + "device_info": async_redact_data(device_info, TO_REDACT), + "service_info": service_info, + "api_info": { + "host": entry.data.get("host", "unknown"), + "port": entry.data.get("port", "unknown"), + "ssl_enabled": entry.data.get("ssl", False), + "ssl_verify": entry.data.get("ssl_verify", False), + }, + "diagnostic_info": { + "collected_at": "Data collected successfully", + "redacted_fields": sorted(TO_REDACT), + "data_collection_errors": "Check individual sections for error details", + }, + } diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index bf0a35a7aff53..0babe24b6d0a4 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/kiosker", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["kiosker-python-api==1.2.5"], + "requirements": ["kiosker-python-api==1.2.6"], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index d669f3a732384..2fa7c07f182e2 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -77,6 +77,13 @@ def parse_datetime(value: Any) -> datetime | None: if hasattr(x, "last_motion") else None, ), + KioskerSensorEntityDescription( + key="ambientLight", + translation_key="ambient_light", + icon="mdi:brightness-6", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda x: x.ambient_light if hasattr(x, "ambient_light") else None, + ), KioskerSensorEntityDescription( key="lastUpdate", translation_key="last_update", diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 3dbc5e2f208fe..12b528f8ccdc5 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -9,14 +9,12 @@ "port": "Port", "api_token": "API Token", "ssl": "Use SSL", - "ssl_verify": "Verify certificate", - "poll_interval": "Poll Interval (seconds)" + "ssl_verify": "Verify certificate" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", "host": "The hostname or IP address of the device running the Kiosker App", "port": "The port on which the Kiosker App is running. Default is 8081.", - "poll_interval": "The interval in seconds to poll the Kiosker App for updates.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." } @@ -30,13 +28,11 @@ "submit": "Pair", "data": { "api_token": "API Token", - "poll_interval": "Poll Interval (seconds)", "ssl": "Use SSL", "ssl_verify": "Verify certificate" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", - "poll_interval": "The interval in seconds to poll the Kiosker App for updates.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." } @@ -46,13 +42,11 @@ "description": "Pair `{name}` at `{host}:{port}`", "data": { "api_token": "API Token", - "poll_interval": "Poll Interval (seconds)", "ssl": "Use SSL", "ssl_verify": "Verify certificate" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", - "poll_interval": "The interval in seconds to poll the Kiosker App for updates.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." } @@ -111,6 +105,9 @@ "last_motion": { "name": "Last motion" }, + "ambient_light": { + "name": "Ambient Light" + }, "last_update": { "name": "Last update" }, diff --git a/requirements_all.txt b/requirements_all.txt index 79bc19666daea..baa3e98b7f0a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1368,7 +1368,7 @@ keba-kecontact==1.3.0 kegtron-ble==1.0.2 # homeassistant.components.kiosker -kiosker-python-api==1.2.5 +kiosker-python-api==1.2.6 # homeassistant.components.kiwi kiwiki-client==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfd91b4eca966..5517ff0730b2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1208,7 +1208,7 @@ justnimbus==0.7.4 kegtron-ble==1.0.2 # homeassistant.components.kiosker -kiosker-python-api==1.2.5 +kiosker-python-api==1.2.6 # homeassistant.components.knocki knocki==0.4.2 diff --git a/tests/components/kiosker/conftest.py b/tests/components/kiosker/conftest.py index c952b7192e48a..e2337a1ba2bdb 100644 --- a/tests/components/kiosker/conftest.py +++ b/tests/components/kiosker/conftest.py @@ -34,7 +34,6 @@ def mock_config_entry() -> MockConfigEntry: "api_token": "test_token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, }, unique_id="A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", ) diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 830085c5fc3f0..8f6e30e494b10 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -75,7 +75,6 @@ async def test_form(hass: HomeAssistant) -> None: "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, }, ) await hass.async_block_till_done() @@ -88,7 +87,6 @@ async def test_form(hass: HomeAssistant) -> None: "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, } assert len(mock_setup_entry.mock_calls) == 1 @@ -111,7 +109,6 @@ async def test_form_invalid_host(hass: HomeAssistant) -> None: "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, }, ) @@ -137,7 +134,6 @@ async def test_form_unexpected_exception(hass: HomeAssistant) -> None: "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, }, ) @@ -225,7 +221,6 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, }, ) await hass.async_block_till_done() @@ -238,7 +233,6 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, } assert len(mock_setup_entry.mock_calls) == 1 @@ -265,7 +259,6 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, }, ) @@ -310,7 +303,6 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, }, ) @@ -367,7 +359,6 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None "api_token": "test-token", "ssl": False, "ssl_verify": False, - "poll_interval": 30, }, ) await hass.async_block_till_done() From dc6428a92daeeaef3ab18be269fafd8a416ca426 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Fri, 24 Oct 2025 22:26:42 +0200 Subject: [PATCH 14/69] tests --- tests/components/kiosker/test_sensor.py | 66 +++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index 27f72d2191ac1..7c75d9d5b5770 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -5,6 +5,7 @@ from datetime import datetime from unittest.mock import MagicMock, patch +from homeassistant.components.kiosker.sensor import parse_datetime from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -67,6 +68,7 @@ async def test_sensors_setup( "sensor.kiosker_a98be1ce_battery_state", "sensor.kiosker_a98be1ce_last_interaction", "sensor.kiosker_a98be1ce_last_motion", + "sensor.kiosker_a98be1ce_ambient_light", "sensor.kiosker_a98be1ce_last_update", "sensor.kiosker_a98be1ce_blackout_state", "sensor.kiosker_a98be1ce_screensaver_visibility", @@ -358,6 +360,64 @@ async def test_last_update_sensor( assert state.attributes["icon"] == "mdi:update" +async def test_ambient_light_sensor( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test ambient light sensor.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = "2025-01-01T12:00:00Z" + mock_status.last_motion = "2025-01-01T11:55:00Z" + mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.ambient_light = 2.6 + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = { + "status": mock_status, + } + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually set coordinator data and trigger update + coordinator = mock_config_entry.runtime_data + coordinator.data = { + "status": mock_status, + } + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check ambient light sensor + state = hass.states.get("sensor.kiosker_a98be1ce_ambient_light") + assert state is not None + assert state.state == "2.6" + assert state.attributes["icon"] == "mdi:brightness-6" + assert state.attributes["state_class"] == "measurement" + # Verify no unit of measurement (unit-less sensor) + assert "unit_of_measurement" not in state.attributes + + async def test_blackout_state_sensor_active( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -629,6 +689,7 @@ async def test_sensors_missing_data( del mock_status.battery_state del mock_status.last_interaction del mock_status.last_motion + del mock_status.ambient_light del mock_status.last_update mock_api.status.return_value = mock_status @@ -663,6 +724,7 @@ async def test_sensors_missing_data( "sensor.kiosker_a98be1ce_battery_state", "sensor.kiosker_a98be1ce_last_interaction", "sensor.kiosker_a98be1ce_last_motion", + "sensor.kiosker_a98be1ce_ambient_light", "sensor.kiosker_a98be1ce_last_update", ] @@ -739,6 +801,7 @@ async def test_sensor_unique_ids( ("sensor.kiosker_test_sen_battery_state", "TEST_SENSOR_ID_battery_state"), ("sensor.kiosker_test_sen_last_interaction", "TEST_SENSOR_ID_last_interaction"), ("sensor.kiosker_test_sen_last_motion", "TEST_SENSOR_ID_last_motion"), + ("sensor.kiosker_test_sen_ambient_light", "TEST_SENSOR_ID_ambient_light"), ("sensor.kiosker_test_sen_last_update", "TEST_SENSOR_ID_last_update"), ("sensor.kiosker_test_sen_blackout_state", "TEST_SENSOR_ID_blackout_state"), ( @@ -755,9 +818,6 @@ async def test_sensor_unique_ids( async def test_parse_datetime_function() -> None: """Test the parse_datetime utility function.""" - # Import here to avoid circular imports during test discovery - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.kiosker.sensor import parse_datetime # Test with None assert parse_datetime(None) is None From 0f51804e9863729edd19efd72dea168cc045e952 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Fri, 24 Oct 2025 23:05:32 +0200 Subject: [PATCH 15/69] Claude changes --- homeassistant/components/kiosker/Claude.md | 2 +- homeassistant/components/kiosker/__init__.py | 140 +++++++++--------- .../components/kiosker/config_flow.py | 24 ++- .../components/kiosker/quality_scale.yaml | 2 +- homeassistant/components/kiosker/strings.json | 15 ++ homeassistant/components/kiosker/switch.py | 49 +++++- tests/components/kiosker/test_services.py | 43 +++--- 7 files changed, 175 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/kiosker/Claude.md b/homeassistant/components/kiosker/Claude.md index 345a130f32d0f..2d98ed87aba35 100644 --- a/homeassistant/components/kiosker/Claude.md +++ b/homeassistant/components/kiosker/Claude.md @@ -8,7 +8,7 @@ The component is an API integration for controlling the Kiosker app from Home As # Versions Support Home Assistant v. 25 -Use kiosker-python-api version 1.2.3 from PyPi +Use kiosker-python-api version 1.2.6 from PyPi # Set up flow - Let the user set up Kiosker visually. diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index a166be10f133f..18abf4287a493 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -68,7 +68,8 @@ async def _get_target_coordinators( # If no targets specified, fail the action if not target_device_ids: raise ServiceValidationError( - "No target devices specified for Kiosker service call" + translation_domain=DOMAIN, + translation_key="no_target_devices", ) # Get device registry @@ -81,15 +82,21 @@ async def _get_target_coordinators( if hasattr(entry, "runtime_data") and entry.runtime_data } - # Find coordinators for target devices + # Find coordinators for target devices - optimized lookup for device_id in target_device_ids: device = device_registry.async_get(device_id) if device: - # Find the coordinator for this device using direct lookup - for entry_id in device.config_entries: - if entry_id in entry_to_coordinator: - coordinators.append(entry_to_coordinator[entry_id]) - break + # Find the first matching coordinator for this device + coordinator = next( + ( + entry_to_coordinator[entry_id] + for entry_id in device.config_entries + if entry_id in entry_to_coordinator + ), + None, + ) + if coordinator: + coordinators.append(coordinator) return coordinators @@ -132,15 +139,23 @@ async def _navigate_url_handler(hass: HomeAssistant, call: ServiceCall) -> None: parsed_url = urlparse(url) if not parsed_url.scheme: raise ServiceValidationError( - f"Invalid URL format: {url}. URL must include a scheme" + translation_domain=DOMAIN, + translation_key="invalid_url_format", + translation_placeholders={"url": url}, ) # For schemes other than http/https, we only validate basic structure if parsed_url.scheme in ("http", "https") and not parsed_url.netloc: raise ServiceValidationError( - f"Invalid URL format: {url}. HTTP/HTTPS URLs must include domain" + translation_domain=DOMAIN, + translation_key="invalid_http_url", + translation_placeholders={"url": url}, ) except (ValueError, TypeError) as exc: - raise ServiceValidationError(f"Failed to parse URL {url}: {exc}") from exc + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="failed_to_parse_url", + translation_placeholders={"url": url, "error": str(exc)}, + ) from exc coordinators = await _get_target_coordinators(hass, call) @@ -281,64 +296,57 @@ async def _blackout_clear_handler(hass: HomeAssistant, call: ServiceCall) -> Non async def _register_services(hass: HomeAssistant) -> None: """Register Kiosker services.""" - async def navigate_url(call: ServiceCall) -> None: - """Navigate to URL service.""" - await _navigate_url_handler(hass, call) - - async def navigate_refresh(call: ServiceCall) -> None: - """Refresh page service.""" - await _navigate_refresh_handler(hass, call) - - async def navigate_home(call: ServiceCall) -> None: - """Navigate home service.""" - await _navigate_home_handler(hass, call) - - async def navigate_backward(call: ServiceCall) -> None: - """Navigate backward service.""" - await _navigate_backward_handler(hass, call) - - async def navigate_forward(call: ServiceCall) -> None: - """Navigate forward service.""" - await _navigate_forward_handler(hass, call) - - async def print_page(call: ServiceCall) -> None: - """Print page service.""" - await _print_page_handler(hass, call) - - async def clear_cookies(call: ServiceCall) -> None: - """Clear cookies service.""" - await _clear_cookies_handler(hass, call) - - async def clear_cache(call: ServiceCall) -> None: - """Clear cache service.""" - await _clear_cache_handler(hass, call) - - async def screensaver_interact(call: ServiceCall) -> None: - """Interact with screensaver service.""" - await _screensaver_interact_handler(hass, call) - - async def blackout_set(call: ServiceCall) -> None: - """Set blackout service.""" - await _blackout_set_handler(hass, call) - - async def blackout_clear(call: ServiceCall) -> None: - """Clear blackout service.""" - await _blackout_clear_handler(hass, call) - - # Register services - hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_URL, navigate_url) - hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_REFRESH, navigate_refresh) - hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_HOME, navigate_home) - hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_BACKWARD, navigate_backward) - hass.services.async_register(DOMAIN, SERVICE_NAVIGATE_FORWARD, navigate_forward) - hass.services.async_register(DOMAIN, SERVICE_PRINT, print_page) - hass.services.async_register(DOMAIN, SERVICE_CLEAR_COOKIES, clear_cookies) - hass.services.async_register(DOMAIN, SERVICE_CLEAR_CACHE, clear_cache) + # Create partial functions with hass parameter bound + def make_service_handler(handler_func): + """Create a service handler with hass parameter bound.""" + + async def service_wrapper(call: ServiceCall) -> None: + await handler_func(hass, call) + + return service_wrapper + + # Register services directly with handlers + hass.services.async_register( + DOMAIN, SERVICE_NAVIGATE_URL, make_service_handler(_navigate_url_handler) + ) + hass.services.async_register( + DOMAIN, + SERVICE_NAVIGATE_REFRESH, + make_service_handler(_navigate_refresh_handler), + ) + hass.services.async_register( + DOMAIN, SERVICE_NAVIGATE_HOME, make_service_handler(_navigate_home_handler) + ) + hass.services.async_register( + DOMAIN, + SERVICE_NAVIGATE_BACKWARD, + make_service_handler(_navigate_backward_handler), + ) + hass.services.async_register( + DOMAIN, + SERVICE_NAVIGATE_FORWARD, + make_service_handler(_navigate_forward_handler), + ) + hass.services.async_register( + DOMAIN, SERVICE_PRINT, make_service_handler(_print_page_handler) + ) + hass.services.async_register( + DOMAIN, SERVICE_CLEAR_COOKIES, make_service_handler(_clear_cookies_handler) + ) + hass.services.async_register( + DOMAIN, SERVICE_CLEAR_CACHE, make_service_handler(_clear_cache_handler) + ) + hass.services.async_register( + DOMAIN, + SERVICE_SCREENSAVER_INTERACT, + make_service_handler(_screensaver_interact_handler), + ) + hass.services.async_register( + DOMAIN, SERVICE_BLACKOUT_SET, make_service_handler(_blackout_set_handler) + ) hass.services.async_register( - DOMAIN, SERVICE_SCREENSAVER_INTERACT, screensaver_interact + DOMAIN, SERVICE_BLACKOUT_CLEAR, make_service_handler(_blackout_clear_handler) ) - hass.services.async_register(DOMAIN, SERVICE_BLACKOUT_SET, blackout_set) - hass.services.async_register(DOMAIN, SERVICE_BLACKOUT_CLEAR, blackout_clear) type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 93a317af920c8..44d2e627709e7 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -115,11 +115,21 @@ async def async_step_user( if hasattr(status, "device_id") else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" ) - except (OSError, TimeoutError, AttributeError) as exc: + except ( + OSError, + TimeoutError, + AttributeError, + ValueError, + TypeError, + KeyError, + ) as exc: _LOGGER.debug("Could not get device ID from status: %s", exc) device_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" - except Exception: # noqa: BLE001 - _LOGGER.debug("Unexpected error getting device ID from status") + except Exception as exc: # noqa: BLE001 + # Broad exception in config flow for robustness during device discovery + _LOGGER.debug( + "Unexpected error getting device ID from status: %s", exc + ) device_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" # Use device ID as unique identifier @@ -208,8 +218,8 @@ async def async_step_zeroconf_confirm( except (ValueError, TypeError) as exc: _LOGGER.error("Invalid discovery data: %s", exc) errors[CONF_API_TOKEN] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception during discovery validation") + except AttributeError as exc: + _LOGGER.error("Invalid discovery data structure: %s", exc) errors["base"] = "unknown" else: return self.async_create_entry(title=info["title"], data=config_data) @@ -271,8 +281,8 @@ async def async_step_reauth_confirm( data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), errors={"base": "invalid_auth"}, ) - except Exception: - _LOGGER.exception("Unexpected exception during reauth") + except (ValueError, TypeError, AttributeError) as exc: + _LOGGER.error("Invalid reauth data: %s", exc) return self.async_show_form( step_id="reauth_confirm", data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 1cebdb6819ad5..7f6bfffc18533 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -33,7 +33,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: done docs-data-update: todo diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 12b528f8ccdc5..76bb141fc7bf8 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -65,6 +65,7 @@ "error": { "cannot_connect": "Failed to connect to Kiosker device.", "invalid_auth": "Authentication failed.", + "invalid_host": "Invalid host or network configuration.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -217,5 +218,19 @@ "name": "Clear blackout", "description": "Clear the blackout screen" } + }, + "exceptions": { + "no_target_devices": { + "message": "No target devices specified for Kiosker service call." + }, + "invalid_url_format": { + "message": "Invalid URL format: {url}. URL must include a scheme." + }, + "invalid_http_url": { + "message": "Invalid URL format: {url}. HTTP/HTTPS URLs must include domain." + }, + "failed_to_parse_url": { + "message": "Failed to parse URL {url}: {error}" + } } } diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py index fe00426330efd..b614fe64454af 100644 --- a/homeassistant/components/kiosker/switch.py +++ b/homeassistant/components/kiosker/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from homeassistant.components.switch import SwitchEntity @@ -12,6 +13,8 @@ from .coordinator import KioskerDataUpdateCoordinator from .entity import KioskerEntity +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 3 @@ -50,14 +53,44 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on (disable screensaver).""" - await self.hass.async_add_executor_job( - self.coordinator.api.screensaver_set_disabled_state, True - ) - await self.coordinator.async_request_refresh() + try: + await self.hass.async_add_executor_job( + self.coordinator.api.screensaver_set_disabled_state, True + ) + await self.coordinator.async_request_refresh() + except (OSError, TimeoutError) as exc: + _LOGGER.error( + "Failed to disable screensaver on device %s: %s", + self.coordinator.api.host, + exc, + ) + raise + except Exception as exc: + _LOGGER.error( + "Unexpected error disabling screensaver on device %s: %s", + self.coordinator.api.host, + exc, + ) + raise async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off (enable screensaver).""" - await self.hass.async_add_executor_job( - self.coordinator.api.screensaver_set_disabled_state, False - ) - await self.coordinator.async_request_refresh() + try: + await self.hass.async_add_executor_job( + self.coordinator.api.screensaver_set_disabled_state, False + ) + await self.coordinator.async_request_refresh() + except (OSError, TimeoutError) as exc: + _LOGGER.error( + "Failed to enable screensaver on device %s: %s", + self.coordinator.api.host, + exc, + ) + raise + except Exception as exc: + _LOGGER.error( + "Unexpected error enabling screensaver on device %s: %s", + self.coordinator.api.host, + exc, + ) + raise diff --git a/tests/components/kiosker/test_services.py b/tests/components/kiosker/test_services.py index 2812e1a0cf87d..2b192e57db01e 100644 --- a/tests/components/kiosker/test_services.py +++ b/tests/components/kiosker/test_services.py @@ -6,6 +6,7 @@ import pytest +from homeassistant.components.kiosker import convert_rgb_to_hex from homeassistant.components.kiosker.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -694,7 +695,7 @@ async def test_service_without_device_target_fails( await hass.async_block_till_done() # Try to call service without target - should raise ServiceValidationError - with pytest.raises(ServiceValidationError, match="No target devices specified"): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, "navigate_refresh", @@ -702,13 +703,13 @@ async def test_service_without_device_target_fails( blocking=True, ) + # Verify the exception has the correct translation attributes + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "no_target_devices" + async def test_convert_rgb_to_hex_function() -> None: """Test the convert_rgb_to_hex utility function.""" - # Import here to avoid circular imports during test discovery - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.kiosker import convert_rgb_to_hex - # Test with hex string (should be preserved) assert convert_rgb_to_hex("#FF0000") == "#FF0000" assert convert_rgb_to_hex("#ff0000") == "#ff0000" @@ -842,56 +843,64 @@ async def test_navigate_url_validation( mock_api.navigate_url.reset_mock() # Test URL without scheme (should be rejected) - with pytest.raises( - ServiceValidationError, match="Invalid URL format.*must include a scheme" - ): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, "navigate_url", {"url": "www.example.com", "device_id": device_id}, blocking=True, ) + + # Verify the exception has the correct translation attributes + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_url_format" # Should not call the API mock_api.navigate_url.assert_not_called() mock_api.navigate_url.reset_mock() # Test HTTP URL without domain (should be rejected) - with pytest.raises( - ServiceValidationError, match="Invalid URL format.*must include domain" - ): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, "navigate_url", {"url": "http://", "device_id": device_id}, blocking=True, ) + + # Verify the exception has the correct translation attributes + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_http_url" # Should not call the API mock_api.navigate_url.assert_not_called() mock_api.navigate_url.reset_mock() # Test HTTPS URL without domain (should be rejected) - with pytest.raises( - ServiceValidationError, match="Invalid URL format.*must include domain" - ): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, "navigate_url", {"url": "https://", "device_id": device_id}, blocking=True, ) + + # Verify the exception has the correct translation attributes + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_http_url" # Should not call the API mock_api.navigate_url.assert_not_called() mock_api.navigate_url.reset_mock() # Test malformed URL that would cause parsing exception - with pytest.raises( - ServiceValidationError, match="Failed to parse URL.*Invalid IPv6 URL" - ): + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, "navigate_url", {"url": "malformed://[invalid", "device_id": device_id}, blocking=True, ) + + # Verify the exception has the correct translation attributes + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "failed_to_parse_url" # Should not call the API mock_api.navigate_url.assert_not_called() From 43c1e8bf2069ad73e2d6de5ed18d2b9c6ec6c2fd Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Tue, 4 Nov 2025 14:30:40 +0100 Subject: [PATCH 16/69] Update --- homeassistant/components/kiosker/__init__.py | 15 +++ homeassistant/components/kiosker/const.py | 3 +- homeassistant/components/kiosker/icons.json | 3 + .../components/kiosker/services.yaml | 105 ++++++++++++------ homeassistant/components/kiosker/strings.json | 90 +++++++++++++-- tests/components/kiosker/test_services.py | 52 +++++++++ 6 files changed, 225 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 18abf4287a493..ae653fc8e4010 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -44,6 +44,7 @@ SERVICE_NAVIGATE_URL, SERVICE_PRINT, SERVICE_SCREENSAVER_INTERACT, + SERVICE_UPDATE, ) from .coordinator import KioskerDataUpdateCoordinator @@ -293,6 +294,13 @@ async def _blackout_clear_handler(hass: HomeAssistant, call: ServiceCall) -> Non await coordinator.async_request_refresh() +async def _update_handler(hass: HomeAssistant, call: ServiceCall) -> None: + """Update device status service.""" + coordinators = await _get_target_coordinators(hass, call) + for coordinator in coordinators: + await coordinator.async_request_refresh() + + async def _register_services(hass: HomeAssistant) -> None: """Register Kiosker services.""" @@ -347,6 +355,9 @@ async def service_wrapper(call: ServiceCall) -> None: hass.services.async_register( DOMAIN, SERVICE_BLACKOUT_CLEAR, make_service_handler(_blackout_clear_handler) ) + hass.services.async_register( + DOMAIN, SERVICE_UPDATE, make_service_handler(_update_handler) + ) type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] @@ -406,6 +417,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + # Start the polling cycle immediately to avoid initial delay + await coordinator.async_refresh() + # Register services globally (only once) - use lock to prevent race conditions async with _SERVICE_REGISTRATION_LOCK: if not hass.services.has_service(DOMAIN, SERVICE_NAVIGATE_URL): @@ -442,6 +456,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> SERVICE_SCREENSAVER_INTERACT, SERVICE_BLACKOUT_SET, SERVICE_BLACKOUT_CLEAR, + SERVICE_UPDATE, ] # Remove each service safely diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py index 40a8a755d3fbb..7975e4f6bdf30 100644 --- a/homeassistant/components/kiosker/const.py +++ b/homeassistant/components/kiosker/const.py @@ -8,7 +8,7 @@ CONF_SSL_VERIFY = "ssl_verify" # Default values DEFAULT_PORT = 8081 -POLL_INTERVAL = 30 # Fixed polling interval in seconds +POLL_INTERVAL = 15 DEFAULT_SSL = False DEFAULT_SSL_VERIFY = False @@ -24,6 +24,7 @@ SERVICE_SCREENSAVER_INTERACT = "screensaver_interact" SERVICE_BLACKOUT_SET = "blackout_set" SERVICE_BLACKOUT_CLEAR = "blackout_clear" +SERVICE_UPDATE = "update" # Attributes ATTR_URL = "url" diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index 6034f7783d0b0..91728c37fd67d 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -32,6 +32,9 @@ }, "blackout_clear": { "service": "mdi:monitor" + }, + "update": { + "service": "mdi:update" } } } diff --git a/homeassistant/components/kiosker/services.yaml b/homeassistant/components/kiosker/services.yaml index 14cf69ff15b2a..5df436210ea63 100644 --- a/homeassistant/components/kiosker/services.yaml +++ b/homeassistant/components/kiosker/services.yaml @@ -1,58 +1,86 @@ navigate_url: - target: - device: - integration: kiosker fields: + device_id: + required: true + selector: + device: + integration: kiosker url: required: true selector: text: navigate_refresh: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker navigate_home: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker navigate_backward: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker navigate_forward: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker print: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker clear_cookies: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker clear_cache: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker screensaver_interact: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker blackout_set: - target: - device: - integration: kiosker fields: + device_id: + required: true + selector: + device: + integration: kiosker visible: default: true selector: @@ -94,6 +122,17 @@ blackout_set: text: blackout_clear: - target: - device: - integration: kiosker + fields: + device_id: + required: true + selector: + device: + integration: kiosker + +update: + fields: + device_id: + required: true + selector: + device: + integration: kiosker diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 76bb141fc7bf8..22e1ec87eb941 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -126,6 +126,10 @@ "name": "Navigate to URL", "description": "Navigate to a specific URL", "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + }, "url": { "name": "URL", "description": "The URL to navigate to" @@ -134,40 +138,92 @@ }, "navigate_refresh": { "name": "Refresh", - "description": "Refresh the current page" + "description": "Refresh the current page", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } }, "navigate_home": { "name": "Navigate home", - "description": "Navigate to the home page" + "description": "Navigate to the home page", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } }, "navigate_backward": { "name": "Navigate backward", - "description": "Navigate to the previous page in history" + "description": "Navigate to the previous page in history", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } }, "navigate_forward": { "name": "Navigate forward", - "description": "Navigate to the next page in history" + "description": "Navigate to the next page in history", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } }, "print": { "name": "Print", - "description": "Print the current page" + "description": "Print the current page", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } }, "clear_cookies": { "name": "Clear cookies", - "description": "Clear all cookies" + "description": "Clear all cookies", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } }, "clear_cache": { "name": "Clear cache", - "description": "Clear the browser cache" + "description": "Clear the browser cache", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } }, "screensaver_interact": { "name": "Screensaver interact", - "description": "Interact with the screensaver" + "description": "Interact with the screensaver", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } }, "blackout_set": { "name": "Set blackout", "description": "Set blackout screen with custom message", "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + }, "visible": { "name": "Visible", "description": "Whether the blackout is visible" @@ -216,7 +272,23 @@ }, "blackout_clear": { "name": "Clear blackout", - "description": "Clear the blackout screen" + "description": "Clear the blackout screen", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } + }, + "update": { + "name": "Update", + "description": "Force an immediate update of device status", + "fields": { + "device_id": { + "name": "Device", + "description": "The Kiosker device to control" + } + } } }, "exceptions": { diff --git a/tests/components/kiosker/test_services.py b/tests/components/kiosker/test_services.py index 2b192e57db01e..465c85cb290be 100644 --- a/tests/components/kiosker/test_services.py +++ b/tests/components/kiosker/test_services.py @@ -904,3 +904,55 @@ async def test_navigate_url_validation( assert exc_info.value.translation_key == "failed_to_parse_url" # Should not call the API mock_api.navigate_url.assert_not_called() + + +async def test_update_service( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test update service.""" + with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_api.status.return_value = mock_status + + # Add the config entry and setup integration + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = {"status": mock_status} + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Get device ID for targeting + device_registry = dr.async_get(hass) + devices = list(device_registry.devices.values()) + assert len(devices) == 1 + device_id = devices[0].id + + # Mock the coordinator's async_request_refresh method + coordinator = mock_config_entry.runtime_data + with patch.object(coordinator, "async_request_refresh") as mock_refresh: + # Call the service + await hass.services.async_call( + DOMAIN, + "update", + {}, + target={"device_id": device_id}, + blocking=True, + ) + + # Verify coordinator refresh was called + mock_refresh.assert_called_once() From 5d70644754fda728055ce905ee614d6cd8fd99c2 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 17 Nov 2025 21:06:46 +0100 Subject: [PATCH 17/69] Adds the Kiosker integration --- homeassistant/components/kiosker/Claude.md | 56 ---------------------- 1 file changed, 56 deletions(-) delete mode 100644 homeassistant/components/kiosker/Claude.md diff --git a/homeassistant/components/kiosker/Claude.md b/homeassistant/components/kiosker/Claude.md deleted file mode 100644 index 2d98ed87aba35..0000000000000 --- a/homeassistant/components/kiosker/Claude.md +++ /dev/null @@ -1,56 +0,0 @@ -# Description -Home assistant component based on PyPi kiosker-python-api. -Call the component Kiosker - -Kiosker is an iOS web kiosk app. See description at https://kiosker.io, and https://docs.kiosker.io - -The component is an API integration for controlling the Kiosker app from Home Assistant. - -# Versions -Support Home Assistant v. 25 -Use kiosker-python-api version 1.2.6 from PyPi - -# Set up flow -- Let the user set up Kiosker visually. -- Let the user configure the connection by host, port (default 8081), API token, and SSL (bool). -- Allow multiple Kiosker instances. - -# Switches -- Disable screensaver: Set: screensaver_set_disabled_state(disabled=bool), Get: state = screensaver_get_state() state.disabled. - -# Actions -- Navigate to URL: navigate_url(url) -- Refresh: navigate_refresh() -- Navigate home: navigate_home() -- Navigate backward: navigate_backward() -- Navigate forward: navigate_forward() - -- Print: print() - -- Clear cookies: clear_cookies() -- Clear cache: clear_cache() - -- Interact with screensaver: screensaver_interact() - -- Blackout: blackout_set(Blackout(visible=True, text='This is a test from Python that should clear', background='#000000', foreground='#FFFFFF', icon='ladybug', expire=20) - -# States -- Device ID: status.device_id -- Model: status.model -- Sw Version: status.os_version -- Battery level: status.battery_level -- Battery state: status.battery_state -- Last interaction: status.last_interaction -- Last motion: status.last_motion -- Last status update: status.last_update - -- Blackout state: blackout_get(), return null if no blackout, else returns a json object. - -# Workflow -- The starting scheme/files in the root folder is generated by a Home Assistant scaffold witch should be used as a starting point. -- Always check your code after changes. -- Use modern Python. -- Look at "api.py", and "data.py" for library usage. -- Main language is English. -- Confirm to quality_scale silver. -- Generate tests. \ No newline at end of file From b40654f57289558a59bf80a93a0ffe2e21ea7534 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Fri, 5 Dec 2025 00:30:12 +0100 Subject: [PATCH 18/69] Reduced to sensor only --- homeassistant/components/kiosker/__init__.py | 420 +------- .../components/kiosker/config_flow.py | 57 -- homeassistant/components/kiosker/const.py | 28 - .../components/kiosker/diagnostics.py | 238 ----- homeassistant/components/kiosker/icons.json | 41 +- .../components/kiosker/manifest.json | 2 +- .../components/kiosker/quality_scale.yaml | 5 +- .../components/kiosker/services.yaml | 138 --- homeassistant/components/kiosker/strings.json | 203 +--- homeassistant/components/kiosker/switch.py | 96 -- tests/components/kiosker/test_config_flow.py | 61 -- tests/components/kiosker/test_init.py | 54 - tests/components/kiosker/test_services.py | 958 ------------------ tests/components/kiosker/test_switch.py | 395 -------- 14 files changed, 13 insertions(+), 2683 deletions(-) delete mode 100644 homeassistant/components/kiosker/diagnostics.py delete mode 100644 homeassistant/components/kiosker/services.yaml delete mode 100644 homeassistant/components/kiosker/switch.py delete mode 100644 tests/components/kiosker/test_services.py delete mode 100644 tests/components/kiosker/test_switch.py diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index ae653fc8e4010..2b588ea9ff208 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -2,393 +2,25 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable -import logging -from urllib.parse import urlparse - -from kiosker import Blackout, KioskerAPI +from kiosker import KioskerAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - ATTR_BACKGROUND, - ATTR_BUTTON_BACKGROUND, - ATTR_BUTTON_FOREGROUND, - ATTR_BUTTON_TEXT, - ATTR_DISMISSIBLE, - ATTR_EXPIRE, - ATTR_FOREGROUND, - ATTR_ICON, - ATTR_SOUND, - ATTR_TEXT, - ATTR_URL, - ATTR_VISIBLE, - CONF_API_TOKEN, - CONF_SSL, - CONF_SSL_VERIFY, - DOMAIN, - SERVICE_BLACKOUT_CLEAR, - SERVICE_BLACKOUT_SET, - SERVICE_CLEAR_CACHE, - SERVICE_CLEAR_COOKIES, - SERVICE_NAVIGATE_BACKWARD, - SERVICE_NAVIGATE_FORWARD, - SERVICE_NAVIGATE_HOME, - SERVICE_NAVIGATE_REFRESH, - SERVICE_NAVIGATE_URL, - SERVICE_PRINT, - SERVICE_SCREENSAVER_INTERACT, - SERVICE_UPDATE, -) +from .const import CONF_API_TOKEN, CONF_SSL, CONF_SSL_VERIFY from .coordinator import KioskerDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] -_LOGGER = logging.getLogger(__name__) -_SERVICE_REGISTRATION_LOCK = asyncio.Lock() +_PLATFORMS: list[Platform] = [Platform.SENSOR] # Limit concurrent updates to prevent overwhelming the API -PARALLEL_UPDATES = 3 - - -async def _get_target_coordinators( - hass: HomeAssistant, call: ServiceCall -) -> list[KioskerDataUpdateCoordinator]: - """Get coordinators for target devices.""" - coordinators: list[KioskerDataUpdateCoordinator] = [] - - # Extract device targets from the service call - referenced = async_extract_referenced_entity_ids(hass, call, expand_group=True) - target_device_ids = referenced.referenced_devices - - # If no targets specified, fail the action - if not target_device_ids: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_target_devices", - ) - - # Get device registry - device_registry = dr.async_get(hass) - - # Create a mapping of config entry ID to coordinator for better performance - entry_to_coordinator = { - entry.entry_id: entry.runtime_data - for entry in hass.config_entries.async_entries(DOMAIN) - if hasattr(entry, "runtime_data") and entry.runtime_data - } - - # Find coordinators for target devices - optimized lookup - for device_id in target_device_ids: - device = device_registry.async_get(device_id) - if device: - # Find the first matching coordinator for this device - coordinator = next( - ( - entry_to_coordinator[entry_id] - for entry_id in device.config_entries - if entry_id in entry_to_coordinator - ), - None, - ) - if coordinator: - coordinators.append(coordinator) - - return coordinators - - -async def _call_api_safe( - hass: HomeAssistant, - coordinator: KioskerDataUpdateCoordinator, - api_method: Callable, - action_name: str, - *args, -) -> None: - """Call API method with error handling and logging.""" - try: - await hass.async_add_executor_job(api_method, *args) - except (OSError, TimeoutError) as exc: - _LOGGER.error( - "Failed to %s on device %s: %s", action_name, coordinator.api.host, exc - ) - except (ValueError, TypeError) as exc: - _LOGGER.error( - "Invalid parameters for %s on device %s: %s", - action_name, - coordinator.api.host, - exc, - ) - except Exception: - _LOGGER.exception( - "Unexpected error during %s on device %s", - action_name, - coordinator.api.host, - ) - - -async def _navigate_url_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Navigate to URL service.""" - url = call.data[ATTR_URL] - - # Validate URL format (allow any scheme including custom ones like "kiosker:") - try: - parsed_url = urlparse(url) - if not parsed_url.scheme: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_url_format", - translation_placeholders={"url": url}, - ) - # For schemes other than http/https, we only validate basic structure - if parsed_url.scheme in ("http", "https") and not parsed_url.netloc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_http_url", - translation_placeholders={"url": url}, - ) - except (ValueError, TypeError) as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="failed_to_parse_url", - translation_placeholders={"url": url, "error": str(exc)}, - ) from exc - - coordinators = await _get_target_coordinators(hass, call) - - for coordinator in coordinators: - await _call_api_safe( - hass, coordinator, coordinator.api.navigate_url, "navigate to URL", url - ) - - -async def _navigate_refresh_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Refresh page service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe( - hass, coordinator, coordinator.api.navigate_refresh, "navigate refresh" - ) - - -async def _navigate_home_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Navigate home service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe( - hass, coordinator, coordinator.api.navigate_home, "navigate home" - ) - - -async def _navigate_backward_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Navigate backward service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe( - hass, - coordinator, - coordinator.api.navigate_backward, - "navigate backward", - ) - - -async def _navigate_forward_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Navigate forward service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe( - hass, coordinator, coordinator.api.navigate_forward, "navigate forward" - ) - - -async def _print_page_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Print page service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe(hass, coordinator, coordinator.api.print, "print page") - - -async def _clear_cookies_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Clear cookies service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe( - hass, coordinator, coordinator.api.clear_cookies, "clear cookies" - ) - - -async def _clear_cache_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Clear cache service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe( - hass, coordinator, coordinator.api.clear_cache, "clear cache" - ) - - -async def _screensaver_interact_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Interact with screensaver service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe( - hass, - coordinator, - coordinator.api.screensaver_interact, - "screensaver interact", - ) - - -async def _blackout_set_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Set blackout service.""" - if Blackout is None: - return - - coordinators = await _get_target_coordinators(hass, call) - - # Convert RGB values to hex format - background_color = convert_rgb_to_hex(call.data.get(ATTR_BACKGROUND, "#000000")) - foreground_color = convert_rgb_to_hex(call.data.get(ATTR_FOREGROUND, "#FFFFFF")) - button_background_color = convert_rgb_to_hex( - call.data.get(ATTR_BUTTON_BACKGROUND, "#FFFFFF") - ) - button_foreground_color = convert_rgb_to_hex( - call.data.get(ATTR_BUTTON_FOREGROUND, "#000000") - ) - - blackout = Blackout( - visible=call.data.get(ATTR_VISIBLE, True), - text=call.data.get(ATTR_TEXT, ""), - background=background_color, - foreground=foreground_color, - icon=call.data.get(ATTR_ICON, ""), - expire=call.data.get(ATTR_EXPIRE, 60), - dismissible=call.data.get(ATTR_DISMISSIBLE, False), - buttonBackground=button_background_color, - buttonForeground=button_foreground_color, - buttonText=call.data.get(ATTR_BUTTON_TEXT, None), - sound=call.data.get(ATTR_SOUND, 0), - ) - - for coordinator in coordinators: - await _call_api_safe( - hass, - coordinator, - coordinator.api.blackout_set, - "blackout set", - blackout, - ) - await coordinator.async_request_refresh() - - -async def _blackout_clear_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Clear blackout service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await _call_api_safe( - hass, coordinator, coordinator.api.blackout_clear, "blackout clear" - ) - await coordinator.async_request_refresh() - - -async def _update_handler(hass: HomeAssistant, call: ServiceCall) -> None: - """Update device status service.""" - coordinators = await _get_target_coordinators(hass, call) - for coordinator in coordinators: - await coordinator.async_request_refresh() - - -async def _register_services(hass: HomeAssistant) -> None: - """Register Kiosker services.""" - - # Create partial functions with hass parameter bound - def make_service_handler(handler_func): - """Create a service handler with hass parameter bound.""" - - async def service_wrapper(call: ServiceCall) -> None: - await handler_func(hass, call) - - return service_wrapper - - # Register services directly with handlers - hass.services.async_register( - DOMAIN, SERVICE_NAVIGATE_URL, make_service_handler(_navigate_url_handler) - ) - hass.services.async_register( - DOMAIN, - SERVICE_NAVIGATE_REFRESH, - make_service_handler(_navigate_refresh_handler), - ) - hass.services.async_register( - DOMAIN, SERVICE_NAVIGATE_HOME, make_service_handler(_navigate_home_handler) - ) - hass.services.async_register( - DOMAIN, - SERVICE_NAVIGATE_BACKWARD, - make_service_handler(_navigate_backward_handler), - ) - hass.services.async_register( - DOMAIN, - SERVICE_NAVIGATE_FORWARD, - make_service_handler(_navigate_forward_handler), - ) - hass.services.async_register( - DOMAIN, SERVICE_PRINT, make_service_handler(_print_page_handler) - ) - hass.services.async_register( - DOMAIN, SERVICE_CLEAR_COOKIES, make_service_handler(_clear_cookies_handler) - ) - hass.services.async_register( - DOMAIN, SERVICE_CLEAR_CACHE, make_service_handler(_clear_cache_handler) - ) - hass.services.async_register( - DOMAIN, - SERVICE_SCREENSAVER_INTERACT, - make_service_handler(_screensaver_interact_handler), - ) - hass.services.async_register( - DOMAIN, SERVICE_BLACKOUT_SET, make_service_handler(_blackout_set_handler) - ) - hass.services.async_register( - DOMAIN, SERVICE_BLACKOUT_CLEAR, make_service_handler(_blackout_clear_handler) - ) - hass.services.async_register( - DOMAIN, SERVICE_UPDATE, make_service_handler(_update_handler) - ) +PARALLEL_UPDATES = 1 type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] -def convert_rgb_to_hex(color: str | list[int]) -> str: - """Convert RGB color to hex format.""" - if isinstance(color, str): - # If already a string, assume it's hex or named color - if color.startswith("#"): - return color - # Handle named colors or other formats - return color - if isinstance(color, list) and len(color) == 3: - try: - # Convert RGB list [r, g, b] to hex format with bounds checking - r, g, b = [max(0, min(255, int(x))) for x in color] - except (ValueError, TypeError) as exc: - _LOGGER.warning( - "Invalid RGB color values %s: %s. Using default color", color, exc - ) - return "#000000" - else: - return f"#{r:02x}{g:02x}{b:02x}" - # Fallback to default if conversion fails - _LOGGER.warning( - "Invalid color format %s. Expected string or list of 3 integers", color - ) - return "#000000" - - async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: """Set up Kiosker from a config entry.""" if KioskerAPI is None: @@ -420,47 +52,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> b # Start the polling cycle immediately to avoid initial delay await coordinator.async_refresh() - # Register services globally (only once) - use lock to prevent race conditions - async with _SERVICE_REGISTRATION_LOCK: - if not hass.services.has_service(DOMAIN, SERVICE_NAVIGATE_URL): - await _register_services(hass) - return True -def _remove_service_safe(hass: HomeAssistant, domain: str, service: str) -> None: - """Safely remove a service if it exists.""" - if hass.services.has_service(domain, service): - hass.services.async_remove(domain, service) - _LOGGER.debug("Removed service %s.%s", domain, service) - else: - _LOGGER.debug("Service %s.%s does not exist, skipping removal", domain, service) - - async def async_unload_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) - - # Only remove services if this is the last kiosker entry - if len(hass.config_entries.async_entries(DOMAIN)) == 1: - # List of all services to remove - services_to_remove = [ - SERVICE_NAVIGATE_URL, - SERVICE_NAVIGATE_REFRESH, - SERVICE_NAVIGATE_HOME, - SERVICE_NAVIGATE_BACKWARD, - SERVICE_NAVIGATE_FORWARD, - SERVICE_PRINT, - SERVICE_CLEAR_COOKIES, - SERVICE_CLEAR_CACHE, - SERVICE_SCREENSAVER_INTERACT, - SERVICE_BLACKOUT_SET, - SERVICE_BLACKOUT_CLEAR, - SERVICE_UPDATE, - ] - - # Remove each service safely - for service in services_to_remove: - _remove_service_safe(hass, DOMAIN, service) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 44d2e627709e7..28c6050ff785b 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping import logging from typing import Any @@ -73,7 +72,6 @@ class ConfigFlow(HAConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 - CONNECTION_CLASS = "local_polling" def __init__(self) -> None: """Initialize the config flow.""" @@ -240,61 +238,6 @@ async def async_step_zeroconf_confirm( errors=errors, ) - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Dialog that informs the user that reauth is required.""" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), - ) - - # Get the config entry that's being re-authenticated - entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - if entry is None: - return self.async_abort(reason="reauth_failed") - - # Create new config data with updated token - new_data = entry.data.copy() - new_data[CONF_API_TOKEN] = user_input[CONF_API_TOKEN] - - # Validate the new token - try: - await validate_input(self.hass, new_data) - except CannotConnect: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), - errors={"base": "invalid_auth"}, - ) - except (ValueError, TypeError) as exc: - _LOGGER.error("Invalid reauth data: %s", exc) - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), - errors={"base": "invalid_auth"}, - ) - except (ValueError, TypeError, AttributeError) as exc: - _LOGGER.error("Invalid reauth data: %s", exc) - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), - errors={"base": "unknown"}, - ) - - # Update the config entry with new token - self.hass.config_entries.async_update_entry(entry, data=new_data) - await self.hass.config_entries.async_reload(entry.entry_id) - - return self.async_abort(reason="reauth_successful") - class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py index 7975e4f6bdf30..6240dcc3c1492 100644 --- a/homeassistant/components/kiosker/const.py +++ b/homeassistant/components/kiosker/const.py @@ -11,31 +11,3 @@ POLL_INTERVAL = 15 DEFAULT_SSL = False DEFAULT_SSL_VERIFY = False - -# Service names -SERVICE_NAVIGATE_URL = "navigate_url" -SERVICE_NAVIGATE_REFRESH = "navigate_refresh" -SERVICE_NAVIGATE_HOME = "navigate_home" -SERVICE_NAVIGATE_BACKWARD = "navigate_backward" -SERVICE_NAVIGATE_FORWARD = "navigate_forward" -SERVICE_PRINT = "print" -SERVICE_CLEAR_COOKIES = "clear_cookies" -SERVICE_CLEAR_CACHE = "clear_cache" -SERVICE_SCREENSAVER_INTERACT = "screensaver_interact" -SERVICE_BLACKOUT_SET = "blackout_set" -SERVICE_BLACKOUT_CLEAR = "blackout_clear" -SERVICE_UPDATE = "update" - -# Attributes -ATTR_URL = "url" -ATTR_VISIBLE = "visible" -ATTR_TEXT = "text" -ATTR_BACKGROUND = "background" -ATTR_FOREGROUND = "foreground" -ATTR_ICON = "icon" -ATTR_EXPIRE = "expire" -ATTR_DISMISSIBLE = "dismissible" -ATTR_BUTTON_BACKGROUND = "button_background" -ATTR_BUTTON_FOREGROUND = "button_foreground" -ATTR_BUTTON_TEXT = "button_text" -ATTR_SOUND = "sound" diff --git a/homeassistant/components/kiosker/diagnostics.py b/homeassistant/components/kiosker/diagnostics.py deleted file mode 100644 index edefa70532304..0000000000000 --- a/homeassistant/components/kiosker/diagnostics.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Diagnostics support for Kiosker integration. - -This module provides comprehensive diagnostic data collection for the Kiosker integration, -including device status, coordinator state, integration health, and service registration -status. Sensitive data like API tokens and device identifiers are automatically redacted -for privacy protection. - -Usage: -- Access via Settings → Devices & Services → Kiosker → Download Diagnostics -- The resulting JSON file contains troubleshooting information safe to share -""" - -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.core import HomeAssistant - -from . import KioskerConfigEntry -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -# Comprehensive list of sensitive data fields to redact -TO_REDACT = { - "api_token", - "token", - "password", - "key", - "secret", - "auth", - "unique_id", - "device_id", - "serial_number", - "mac_address", - "macaddress", - "latitude", - "longitude", - "location", - "address", - "coordinates", - "wifi_ssid", - "network_name", - "ssid", - "username", - "email", -} - -# Device status fields to extract safely -DEVICE_STATUS_FIELDS = [ - "model", - "os_version", - "app_name", - "app_version", - "battery_level", - "battery_state", - "last_interaction", - "last_motion", - "last_update", - "ambient_light", -] - -# Kiosker service names for service registration check -KIOSKER_SERVICES = [ - "navigate_url", - "navigate_refresh", - "navigate_home", - "navigate_backward", - "navigate_forward", - "print", - "clear_cookies", - "clear_cache", - "screensaver_interact", - "blackout_set", - "blackout_clear", -] - - -def _safe_get_device_info(coordinator_data: dict[str, Any]) -> dict[str, Any]: - """Safely extract device information from coordinator data.""" - device_info = {} - - try: - if coordinator_data.get("status"): - status = coordinator_data["status"] - - # Use dictionary comprehension for cleaner field extraction - device_info = { - field: getattr(status, field, None) for field in DEVICE_STATUS_FIELDS - } - - except (AttributeError, KeyError, TypeError) as err: - _LOGGER.warning("Failed to extract device info for diagnostics: %s", err) - device_info = {"extraction_error": f"Failed to extract device info: {err}"} - - return device_info - - -def _safe_get_coordinator_data(coordinator) -> dict[str, Any]: - """Safely extract coordinator data with error handling.""" - try: - return coordinator.data or {} - except (AttributeError, TypeError) as err: - _LOGGER.warning("Failed to access coordinator data: %s", err) - return {"access_error": f"Failed to access coordinator data: {err}"} - - -def _get_coordinator_metadata(coordinator) -> dict[str, Any]: - """Extract coordinator metadata safely.""" - try: - # Sanitize exception message to avoid sensitive data leakage - last_exception = None - if coordinator.last_exception: - exception_msg = str(coordinator.last_exception) - # Remove potential sensitive info from exception messages - if any( - sensitive in exception_msg.lower() - for sensitive in ("token", "password", "key") - ): - last_exception = "Authentication or credential error (details hidden)" - else: - last_exception = exception_msg - - return { - "last_update_success": coordinator.last_update_success, - "last_exception": last_exception, - "update_interval_seconds": coordinator.update_interval.total_seconds(), - "name": coordinator.name, - "last_update_success_time": getattr( - coordinator, "last_update_success_time", None - ), - } - except (AttributeError, TypeError) as err: - _LOGGER.warning("Failed to extract coordinator metadata: %s", err) - return {"metadata_error": f"Failed to extract metadata: {err}"} - - -def _get_integration_info(entry: KioskerConfigEntry) -> dict[str, Any]: - """Get integration state and setup information.""" - try: - return { - "version": getattr(entry, "version", 1), - "minor_version": getattr(entry, "minor_version", 1), - "state": entry.state.value if entry.state else "unknown", - "title": entry.title, - "domain": entry.domain, - "setup_retry_count": getattr(entry, "_setup_retry_count", 0), - "supports_unload": entry.supports_unload, - "supports_remove_device": entry.supports_remove_device, - } - except (AttributeError, TypeError) as err: - _LOGGER.warning("Failed to extract integration info: %s", err) - return {"integration_error": f"Failed to extract integration info: {err}"} - - -def _get_service_info(hass: HomeAssistant) -> dict[str, Any]: - """Get service registration status.""" - try: - services_registered = [] - services_missing = [] - - for service in KIOSKER_SERVICES: - if hass.services.has_service(DOMAIN, service): - services_registered.append(service) - else: - services_missing.append(service) - - return { - "services_registered": services_registered, - "services_missing": services_missing, - "total_services_expected": len(KIOSKER_SERVICES), - "registration_complete": len(services_missing) == 0, - } - except (AttributeError, TypeError) as err: - _LOGGER.warning("Failed to extract service info: %s", err) - return {"service_error": f"Failed to extract service info: {err}"} - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: KioskerConfigEntry -) -> dict[str, Any]: - """Return comprehensive diagnostics for a Kiosker config entry. - - Collects device status, coordinator state, integration health, service - registration status, and configuration details while protecting sensitive - data through automatic redaction. - - Args: - hass: Home Assistant instance - entry: Kiosker configuration entry - - Returns: - Dictionary containing diagnostic data with sensitive fields redacted - """ - try: - coordinator = entry.runtime_data - except (AttributeError, TypeError) as err: - _LOGGER.error("Failed to access runtime data for diagnostics: %s", err) - return { - "error": "Failed to access integration runtime data", - "entry_basic_info": { - "title": entry.title, - "domain": entry.domain, - "state": entry.state.value if entry.state else "unknown", - }, - } - - # Safely collect all diagnostic data - coordinator_data = _safe_get_coordinator_data(coordinator) - device_info = _safe_get_device_info(coordinator_data) - coordinator_metadata = _get_coordinator_metadata(coordinator) - integration_info = _get_integration_info(entry) - service_info = _get_service_info(hass) - - # Build comprehensive diagnostics dictionary - - return { - "entry_data": async_redact_data(entry.data, TO_REDACT), - "entry_options": async_redact_data(entry.options, TO_REDACT), - "integration_info": integration_info, - "coordinator_data": async_redact_data(coordinator_data, TO_REDACT), - "coordinator_metadata": coordinator_metadata, - "device_info": async_redact_data(device_info, TO_REDACT), - "service_info": service_info, - "api_info": { - "host": entry.data.get("host", "unknown"), - "port": entry.data.get("port", "unknown"), - "ssl_enabled": entry.data.get("ssl", False), - "ssl_verify": entry.data.get("ssl_verify", False), - }, - "diagnostic_info": { - "collected_at": "Data collected successfully", - "redacted_fields": sorted(TO_REDACT), - "data_collection_errors": "Check individual sections for error details", - }, - } diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index 91728c37fd67d..0967ef424bce6 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -1,40 +1 @@ -{ - "services": { - "navigate_url": { - "service": "mdi:web" - }, - "navigate_refresh": { - "service": "mdi:refresh" - }, - "navigate_home": { - "service": "mdi:home" - }, - "navigate_backward": { - "service": "mdi:arrow-left" - }, - "navigate_forward": { - "service": "mdi:arrow-right" - }, - "print": { - "service": "mdi:printer" - }, - "clear_cookies": { - "service": "mdi:cookie-remove" - }, - "clear_cache": { - "service": "mdi:cached" - }, - "screensaver_interact": { - "service": "mdi:monitor-screenshot" - }, - "blackout_set": { - "service": "mdi:monitor-off" - }, - "blackout_clear": { - "service": "mdi:monitor" - }, - "update": { - "service": "mdi:update" - } - } -} +{} diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index 0babe24b6d0a4..a2968ab5232a0 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kiosker", "iot_class": "local_polling", - "quality_scale": "silver", + "quality_scale": "bronze", "requirements": ["kiosker-python-api==1.2.6"], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 7f6bfffc18533..e897d4c1ad831 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -28,12 +28,13 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: done + reauthentication-flow: + status: exempt + comment: Removed from initial PR per review feedback test-coverage: done # Gold devices: done - diagnostics: done discovery-update-info: todo discovery: done docs-data-update: todo diff --git a/homeassistant/components/kiosker/services.yaml b/homeassistant/components/kiosker/services.yaml deleted file mode 100644 index 5df436210ea63..0000000000000 --- a/homeassistant/components/kiosker/services.yaml +++ /dev/null @@ -1,138 +0,0 @@ -navigate_url: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - url: - required: true - selector: - text: - -navigate_refresh: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -navigate_home: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -navigate_backward: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -navigate_forward: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -print: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -clear_cookies: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -clear_cache: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -screensaver_interact: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -blackout_set: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - visible: - default: true - selector: - boolean: - text: - default: "Hello, World!" - selector: - text: - background: - selector: - color_rgb: - foreground: - selector: - color_rgb: - icon: - selector: - text: - expire: - default: 60 - selector: - number: - min: 0 - max: 3600 - unit_of_measurement: seconds - dismissible: - selector: - boolean: - button_background: - selector: - color_rgb: - button_foreground: - selector: - color_rgb: - button_text: - selector: - text: - sound: - selector: - text: - -blackout_clear: - fields: - device_id: - required: true - selector: - device: - integration: kiosker - -update: - fields: - device_id: - required: true - selector: - device: - integration: kiosker diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 22e1ec87eb941..d5073c0c48dd0 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -50,16 +50,6 @@ "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." } - }, - "reauth_confirm": { - "title": "Reauthenticate Kiosker", - "description": "Authentication failed. This may be caused by an invalid API token or changed IP filtering settings. Please update the API token, or the IP filter in the Kiosker App.", - "data": { - "api_token": "API Token" - }, - "data_description": { - "api_token": "Enter the new API token for the Kiosker App. This can be generated in the app API settings." - } } }, "error": { @@ -70,17 +60,10 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "reauth_successful": "Reauthentication was successful", - "reauth_failed": "Reauthentication failed" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" } }, "entity": { - "switch": { - "disable_screensaver": { - "name": "Disable screensaver" - } - }, "sensor": { "battery_level": { "name": "Battery level" @@ -120,189 +103,5 @@ } } } - }, - "services": { - "navigate_url": { - "name": "Navigate to URL", - "description": "Navigate to a specific URL", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - }, - "url": { - "name": "URL", - "description": "The URL to navigate to" - } - } - }, - "navigate_refresh": { - "name": "Refresh", - "description": "Refresh the current page", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "navigate_home": { - "name": "Navigate home", - "description": "Navigate to the home page", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "navigate_backward": { - "name": "Navigate backward", - "description": "Navigate to the previous page in history", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "navigate_forward": { - "name": "Navigate forward", - "description": "Navigate to the next page in history", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "print": { - "name": "Print", - "description": "Print the current page", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "clear_cookies": { - "name": "Clear cookies", - "description": "Clear all cookies", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "clear_cache": { - "name": "Clear cache", - "description": "Clear the browser cache", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "screensaver_interact": { - "name": "Screensaver interact", - "description": "Interact with the screensaver", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "blackout_set": { - "name": "Set blackout", - "description": "Set blackout screen with custom message", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - }, - "visible": { - "name": "Visible", - "description": "Whether the blackout is visible" - }, - "text": { - "name": "Text", - "description": "Text to display on blackout screen" - }, - "background": { - "name": "Background color", - "description": "Background color in hex format" - }, - "foreground": { - "name": "Foreground color", - "description": "Text color in hex format" - }, - "icon": { - "name": "Icon", - "description": "Icon to display (SF Symbols name)" - }, - "expire": { - "name": "Expire time", - "description": "Time in seconds before the blackout expires" - }, - "dismissible": { - "name": "Dismissible", - "description": "Whether the blackout can be dismissed by user interaction" - }, - "button_background": { - "name": "Button background color", - "description": "Background color of the dismiss button in hex format" - }, - "button_foreground": { - "name": "Button foreground color", - "description": "Text color of the dismiss button in hex format" - }, - "button_text": { - "name": "Button text", - "description": "Text to display on the dismiss button" - }, - "sound": { - "name": "Sound", - "description": "Sound to play when blackout is displayed (SystemSoundID, e.g., 1007)" - } - } - }, - "blackout_clear": { - "name": "Clear blackout", - "description": "Clear the blackout screen", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - }, - "update": { - "name": "Update", - "description": "Force an immediate update of device status", - "fields": { - "device_id": { - "name": "Device", - "description": "The Kiosker device to control" - } - } - } - }, - "exceptions": { - "no_target_devices": { - "message": "No target devices specified for Kiosker service call." - }, - "invalid_url_format": { - "message": "Invalid URL format: {url}. URL must include a scheme." - }, - "invalid_http_url": { - "message": "Invalid URL format: {url}. HTTP/HTTPS URLs must include domain." - }, - "failed_to_parse_url": { - "message": "Failed to parse URL {url}: {error}" - } } } diff --git a/homeassistant/components/kiosker/switch.py b/homeassistant/components/kiosker/switch.py deleted file mode 100644 index b614fe64454af..0000000000000 --- a/homeassistant/components/kiosker/switch.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Switch platform for Kiosker.""" - -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import KioskerConfigEntry -from .coordinator import KioskerDataUpdateCoordinator -from .entity import KioskerEntity - -_LOGGER = logging.getLogger(__name__) - -PARALLEL_UPDATES = 3 - - -async def async_setup_entry( - hass: HomeAssistant, - entry: KioskerConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Kiosker switch based on a config entry.""" - coordinator = entry.runtime_data - async_add_entities([KioskerScreensaverSwitch(coordinator)]) - - -class KioskerScreensaverSwitch(KioskerEntity, SwitchEntity): - """Screensaver disable switch for Kiosker.""" - - _attr_has_entity_name = True - _attr_translation_key = "disable_screensaver" - - def __init__(self, coordinator: KioskerDataUpdateCoordinator) -> None: - """Initialize the screensaver switch.""" - - super().__init__(coordinator) - - device_id = self._get_device_id() - self._attr_unique_id = f"{device_id}_{self._attr_translation_key}" - - @property - def is_on(self) -> bool | None: - """Return true if screensaver is disabled.""" - if self.coordinator.data and "screensaver" in self.coordinator.data: - screensaver_state = self.coordinator.data["screensaver"] - if hasattr(screensaver_state, "disabled"): - return screensaver_state.disabled - return None - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the switch on (disable screensaver).""" - try: - await self.hass.async_add_executor_job( - self.coordinator.api.screensaver_set_disabled_state, True - ) - await self.coordinator.async_request_refresh() - except (OSError, TimeoutError) as exc: - _LOGGER.error( - "Failed to disable screensaver on device %s: %s", - self.coordinator.api.host, - exc, - ) - raise - except Exception as exc: - _LOGGER.error( - "Unexpected error disabling screensaver on device %s: %s", - self.coordinator.api.host, - exc, - ) - raise - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the switch off (enable screensaver).""" - try: - await self.hass.async_add_executor_job( - self.coordinator.api.screensaver_set_disabled_state, False - ) - await self.coordinator.async_request_refresh() - except (OSError, TimeoutError) as exc: - _LOGGER.error( - "Failed to enable screensaver on device %s: %s", - self.coordinator.api.host, - exc, - ) - raise - except Exception as exc: - _LOGGER.error( - "Unexpected error enabling screensaver on device %s: %s", - self.coordinator.api.host, - exc, - ) - raise diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 8f6e30e494b10..39c8948163995 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -367,67 +367,6 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None assert result2["title"] == "Kiosker 192.168.1.100" -async def test_reauth_flow(hass: HomeAssistant) -> None: - """Test reauth flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "old_token"}, - unique_id="test-device-123", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id}, - data=entry.data, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - with patch( - "homeassistant.components.kiosker.config_flow.validate_input" - ) as mock_validate: - mock_validate.return_value = {"title": "Kiosker test-device-123"} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"api_token": "new_token"}, - ) - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert entry.data["api_token"] == "new_token" - - -async def test_reauth_flow_cannot_connect(hass: HomeAssistant) -> None: - """Test reauth flow with connection error.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "old_token"}, - unique_id="test-device-123", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id}, - data=entry.data, - ) - - with patch( - "homeassistant.components.kiosker.config_flow.validate_input", - side_effect=CannotConnect, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"api_token": "invalid_token"}, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - async def test_validate_input_success( hass: HomeAssistant, mock_kiosker_api: Mock, diff --git a/tests/components/kiosker/test_init.py b/tests/components/kiosker/test_init.py index 0d77f556140a8..c2d9b7c7b711b 100644 --- a/tests/components/kiosker/test_init.py +++ b/tests/components/kiosker/test_init.py @@ -147,57 +147,3 @@ async def test_device_identifiers_and_info( assert device_entry.hw_version == "iPad Mini (17.5)" assert device_entry.serial_number == "TEST_DEVICE_123" assert device_entry.identifiers == {(DOMAIN, "TEST_DEVICE_123")} - - -async def test_service_registration_and_unregistration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that services are registered during setup and unregistered during unload.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - - # Before setup - services should not exist - assert not hass.services.has_service(DOMAIN, "navigate_url") - assert not hass.services.has_service(DOMAIN, "blackout_set") - - # Setup integration - await setup_integration(hass, mock_config_entry) - - # After setup - services should exist - assert hass.services.has_service(DOMAIN, "navigate_url") - assert hass.services.has_service(DOMAIN, "navigate_refresh") - assert hass.services.has_service(DOMAIN, "navigate_home") - assert hass.services.has_service(DOMAIN, "navigate_backward") - assert hass.services.has_service(DOMAIN, "navigate_forward") - assert hass.services.has_service(DOMAIN, "print") - assert hass.services.has_service(DOMAIN, "clear_cookies") - assert hass.services.has_service(DOMAIN, "clear_cache") - assert hass.services.has_service(DOMAIN, "screensaver_interact") - assert hass.services.has_service(DOMAIN, "blackout_set") - assert hass.services.has_service(DOMAIN, "blackout_clear") - - # Unload integration - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # After unload - services should be removed (if this was the last entry) - assert not hass.services.has_service(DOMAIN, "navigate_url") - assert not hass.services.has_service(DOMAIN, "blackout_set") diff --git a/tests/components/kiosker/test_services.py b/tests/components/kiosker/test_services.py deleted file mode 100644 index 465c85cb290be..0000000000000 --- a/tests/components/kiosker/test_services.py +++ /dev/null @@ -1,958 +0,0 @@ -"""Test the Kiosker services.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from homeassistant.components.kiosker import convert_rgb_to_hex -from homeassistant.components.kiosker.const import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr - -from tests.common import MockConfigEntry - - -async def test_navigate_url_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test navigate_url service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.navigate_url = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - assert len(devices) == 1 - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "navigate_url", - { - "url": "https://example.com", - }, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.navigate_url.assert_called_once_with("https://example.com") - - -async def test_navigate_refresh_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test navigate_refresh service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.navigate_refresh = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "navigate_refresh", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.navigate_refresh.assert_called_once() - - -async def test_navigate_home_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test navigate_home service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.navigate_home = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "navigate_home", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.navigate_home.assert_called_once() - - -async def test_navigate_backward_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test navigate_backward service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.navigate_backward = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "navigate_backward", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.navigate_backward.assert_called_once() - - -async def test_navigate_forward_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test navigate_forward service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.navigate_forward = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "navigate_forward", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.navigate_forward.assert_called_once() - - -async def test_print_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test print service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.print = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "print", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.print.assert_called_once() - - -async def test_clear_cookies_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test clear_cookies service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.clear_cookies = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "clear_cookies", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.clear_cookies.assert_called_once() - - -async def test_clear_cache_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test clear_cache service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.clear_cache = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "clear_cache", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.clear_cache.assert_called_once() - - -async def test_screensaver_interact_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test screensaver_interact service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.screensaver_interact = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Call the service - await hass.services.async_call( - DOMAIN, - "screensaver_interact", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify API was called - mock_api.screensaver_interact.assert_called_once() - - -async def test_blackout_set_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test blackout_set service with all parameters.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.blackout_set = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Mock the coordinator's async_request_refresh method - coordinator = mock_config_entry.runtime_data - with patch.object(coordinator, "async_request_refresh") as mock_refresh: - # Call the service with all parameters - await hass.services.async_call( - DOMAIN, - "blackout_set", - { - "visible": True, - "text": "Test Message", - "background": [255, 0, 0], # RGB red - "foreground": "#00FF00", # Hex green - "icon": "alert", - "expire": 120, - "dismissible": True, - "button_background": [0, 0, 255], # RGB blue - "button_foreground": "#FFFF00", # Hex yellow - "button_text": "Dismiss", - "sound": 1, - }, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify coordinator refresh was called - mock_refresh.assert_called_once() - - # Verify API was called - mock_api.blackout_set.assert_called_once() - - # Check the blackout object passed to API - blackout_call = mock_api.blackout_set.call_args[0][0] - assert blackout_call.visible is True - assert blackout_call.text == "Test Message" - assert blackout_call.background == "#ff0000" # RGB converted to hex - assert blackout_call.foreground == "#00FF00" # Hex preserved - assert blackout_call.icon == "alert" - assert blackout_call.expire == 120 - assert blackout_call.dismissible is True - assert blackout_call.buttonBackground == "#0000ff" # RGB converted to hex - assert blackout_call.buttonForeground == "#FFFF00" # Hex preserved - assert blackout_call.buttonText == "Dismiss" - assert blackout_call.sound == 1 - - -async def test_blackout_set_service_defaults( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test blackout_set service with default values.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.blackout_set = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Mock the coordinator's async_request_refresh method - coordinator = mock_config_entry.runtime_data - with patch.object(coordinator, "async_request_refresh") as mock_refresh: - # Call the service with minimal parameters (testing defaults) - await hass.services.async_call( - DOMAIN, - "blackout_set", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify coordinator refresh was called - mock_refresh.assert_called_once() - - # Verify API was called - mock_api.blackout_set.assert_called_once() - - # Check the blackout object with default values - blackout_call = mock_api.blackout_set.call_args[0][0] - assert blackout_call.visible is True # Default - assert blackout_call.text == "" # Default - assert blackout_call.background == "#000000" # Default - assert blackout_call.foreground == "#FFFFFF" # Default - assert blackout_call.icon == "" # Default - assert blackout_call.expire == 60 # Default - assert blackout_call.dismissible is False # Default - assert blackout_call.buttonBackground == "#FFFFFF" # Default - assert blackout_call.buttonForeground == "#000000" # Default - assert blackout_call.buttonText is None # Default - assert blackout_call.sound == 0 # Default - - -async def test_blackout_clear_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test blackout_clear service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.blackout_clear = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - device_id = devices[0].id - - # Mock the coordinator's async_request_refresh method - coordinator = mock_config_entry.runtime_data - with patch.object(coordinator, "async_request_refresh") as mock_refresh: - # Call the service - await hass.services.async_call( - DOMAIN, - "blackout_clear", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify coordinator refresh was called - mock_refresh.assert_called_once() - - # Verify API was called - mock_api.blackout_clear.assert_called_once() - - -async def test_service_without_device_target_fails( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that services fail when no device is targeted.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Try to call service without target - should raise ServiceValidationError - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - "navigate_refresh", - {}, - blocking=True, - ) - - # Verify the exception has the correct translation attributes - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "no_target_devices" - - -async def test_convert_rgb_to_hex_function() -> None: - """Test the convert_rgb_to_hex utility function.""" - # Test with hex string (should be preserved) - assert convert_rgb_to_hex("#FF0000") == "#FF0000" - assert convert_rgb_to_hex("#ff0000") == "#ff0000" - assert convert_rgb_to_hex("#123456") == "#123456" - - # Test with named color string (should be preserved) - assert convert_rgb_to_hex("red") == "red" - assert convert_rgb_to_hex("blue") == "blue" - assert convert_rgb_to_hex("transparent") == "transparent" - - # Test with valid RGB lists - assert convert_rgb_to_hex([255, 0, 0]) == "#ff0000" - assert convert_rgb_to_hex([0, 255, 0]) == "#00ff00" - assert convert_rgb_to_hex([0, 0, 255]) == "#0000ff" - assert convert_rgb_to_hex([128, 64, 192]) == "#8040c0" - assert convert_rgb_to_hex([0, 0, 0]) == "#000000" - assert convert_rgb_to_hex([255, 255, 255]) == "#ffffff" - - # Test bounds checking - values should be clamped to 0-255 range - assert convert_rgb_to_hex([300, 0, 0]) == "#ff0000" # 300 clamped to 255 - assert convert_rgb_to_hex([-10, 0, 0]) == "#000000" # -10 clamped to 0 - assert convert_rgb_to_hex([128, 300, -50]) == "#80ff00" # Mixed bounds - assert convert_rgb_to_hex([1000, -1000, 500]) == "#ff00ff" # Extreme values - - # Test type conversion within RGB lists - assert convert_rgb_to_hex([255.0, 128.9, 0.1]) == "#ff8000" # Float to int - assert convert_rgb_to_hex(["255", "128", "0"]) == "#ff8000" # String to int - - # Test invalid RGB value types (should fallback to default) - assert convert_rgb_to_hex([255, "invalid", 0]) == "#000000" - assert convert_rgb_to_hex([255, None, 0]) == "#000000" - assert convert_rgb_to_hex([255, [], 0]) == "#000000" - - # Test invalid list lengths (should return default) - assert convert_rgb_to_hex([255, 0]) == "#000000" # Too short - assert convert_rgb_to_hex([255, 0, 0, 128]) == "#000000" # Too long - assert convert_rgb_to_hex([]) == "#000000" # Empty list - - # Test invalid input types (should return default) - assert convert_rgb_to_hex(None) == "#000000" - assert convert_rgb_to_hex(123) == "#000000" - assert convert_rgb_to_hex(123.45) == "#000000" - assert convert_rgb_to_hex({}) == "#000000" - assert convert_rgb_to_hex(set()) == "#000000" - - # Test edge cases - assert convert_rgb_to_hex("") == "" # Empty string should be preserved - assert ( - convert_rgb_to_hex("not_hex_not_starting_with_hash") - == "not_hex_not_starting_with_hash" - ) - - -async def test_navigate_url_validation( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test URL validation in navigate_url service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.navigate_url = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - assert len(devices) == 1 - device_id = devices[0].id - - # Test valid HTTP URL - await hass.services.async_call( - DOMAIN, - "navigate_url", - {"url": "https://www.example.com", "device_id": device_id}, - blocking=True, - ) - mock_api.navigate_url.assert_called_with("https://www.example.com") - mock_api.navigate_url.reset_mock() - - # Test valid HTTPS URL with path and query - await hass.services.async_call( - DOMAIN, - "navigate_url", - {"url": "https://www.example.com/path?param=value", "device_id": device_id}, - blocking=True, - ) - mock_api.navigate_url.assert_called_with( - "https://www.example.com/path?param=value" - ) - mock_api.navigate_url.reset_mock() - - # Test valid custom scheme (like kiosker:) - await hass.services.async_call( - DOMAIN, - "navigate_url", - {"url": "kiosker://reload", "device_id": device_id}, - blocking=True, - ) - mock_api.navigate_url.assert_called_with("kiosker://reload") - mock_api.navigate_url.reset_mock() - - # Test other custom schemes - await hass.services.async_call( - DOMAIN, - "navigate_url", - {"url": "file:///path/to/file.html", "device_id": device_id}, - blocking=True, - ) - mock_api.navigate_url.assert_called_with("file:///path/to/file.html") - mock_api.navigate_url.reset_mock() - - # Test URL without scheme (should be rejected) - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - "navigate_url", - {"url": "www.example.com", "device_id": device_id}, - blocking=True, - ) - - # Verify the exception has the correct translation attributes - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "invalid_url_format" - # Should not call the API - mock_api.navigate_url.assert_not_called() - mock_api.navigate_url.reset_mock() - - # Test HTTP URL without domain (should be rejected) - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - "navigate_url", - {"url": "http://", "device_id": device_id}, - blocking=True, - ) - - # Verify the exception has the correct translation attributes - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "invalid_http_url" - # Should not call the API - mock_api.navigate_url.assert_not_called() - mock_api.navigate_url.reset_mock() - - # Test HTTPS URL without domain (should be rejected) - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - "navigate_url", - {"url": "https://", "device_id": device_id}, - blocking=True, - ) - - # Verify the exception has the correct translation attributes - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "invalid_http_url" - # Should not call the API - mock_api.navigate_url.assert_not_called() - mock_api.navigate_url.reset_mock() - - # Test malformed URL that would cause parsing exception - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - "navigate_url", - {"url": "malformed://[invalid", "device_id": device_id}, - blocking=True, - ) - - # Verify the exception has the correct translation attributes - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "failed_to_parse_url" - # Should not call the API - mock_api.navigate_url.assert_not_called() - - -async def test_update_service( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test update service.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry and setup integration - mock_config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = {"status": mock_status} - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Get device ID for targeting - device_registry = dr.async_get(hass) - devices = list(device_registry.devices.values()) - assert len(devices) == 1 - device_id = devices[0].id - - # Mock the coordinator's async_request_refresh method - coordinator = mock_config_entry.runtime_data - with patch.object(coordinator, "async_request_refresh") as mock_refresh: - # Call the service - await hass.services.async_call( - DOMAIN, - "update", - {}, - target={"device_id": device_id}, - blocking=True, - ) - - # Verify coordinator refresh was called - mock_refresh.assert_called_once() diff --git a/tests/components/kiosker/test_switch.py b/tests/components/kiosker/test_switch.py deleted file mode 100644 index 25be3dc048874..0000000000000 --- a/tests/components/kiosker/test_switch.py +++ /dev/null @@ -1,395 +0,0 @@ -"""Test the Kiosker switch.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from tests.common import MockConfigEntry - - -async def test_screensaver_switch_setup( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test setting up switch.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data that coordinator will return - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_screensaver = MagicMock() - mock_screensaver.disabled = False - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration with proper coordinator mocking - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - } - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Check that the switch entity was created - state = hass.states.get("switch.kiosker_a98be1ce_disable_screensaver") - assert state is not None - - # Check entity registry - entity_registry = er.async_get(hass) - entity = entity_registry.async_get("switch.kiosker_a98be1ce_disable_screensaver") - assert entity is not None - assert ( - entity.unique_id == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC_disable_screensaver" - ) - - -async def test_screensaver_switch_is_on_true( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test switch is_on property when screensaver is disabled.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - screensaver is disabled (switch is on) - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_screensaver = MagicMock() - mock_screensaver.disabled = True - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - } - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Check that the switch is on (screensaver disabled) - state = hass.states.get("switch.kiosker_a98be1ce_disable_screensaver") - assert state is not None - assert state.state == "on" - - -async def test_screensaver_switch_is_on_false( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test switch is_on property when screensaver is enabled.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - screensaver is enabled (switch is off) - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_screensaver = MagicMock() - mock_screensaver.disabled = False - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - } - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Check that the switch is off (screensaver enabled) - state = hass.states.get("switch.kiosker_a98be1ce_disable_screensaver") - assert state is not None - assert state.state == "off" - - -async def test_screensaver_switch_is_on_none( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test switch is_on property when screensaver data is unavailable.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data with no screensaver data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = None - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration with no screensaver data - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = { - "status": mock_status, - # No screensaver key - } - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Check that the switch is unknown (no screensaver data) - state = hass.states.get("switch.kiosker_a98be1ce_disable_screensaver") - assert state is not None - assert state.state == "unknown" - - -async def test_screensaver_switch_turn_on( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test turning on the switch (disabling screensaver).""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.screensaver_set_disabled_state = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_screensaver = MagicMock() - mock_screensaver.disabled = False - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with ( - patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update, - patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator.async_request_refresh" - ) as mock_refresh, - ): - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - } - mock_refresh.return_value = None - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Turn on the switch - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.kiosker_a98be1ce_disable_screensaver"}, - blocking=True, - ) - - # Verify API was called to disable screensaver - mock_api.screensaver_set_disabled_state.assert_called_once_with(True) - mock_refresh.assert_called() - - -async def test_screensaver_switch_turn_off( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test turning off the switch (enabling screensaver).""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.screensaver_set_disabled_state = MagicMock() - mock_api_class.return_value = mock_api - - # Setup mock data - initially disabled - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_screensaver = MagicMock() - mock_screensaver.disabled = True - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with ( - patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update, - patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator.async_request_refresh" - ) as mock_refresh, - ): - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - } - mock_refresh.return_value = None - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Turn off the switch - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.kiosker_a98be1ce_disable_screensaver"}, - blocking=True, - ) - - # Verify API was called to enable screensaver - mock_api.screensaver_set_disabled_state.assert_called_once_with(False) - mock_refresh.assert_called() - - -async def test_screensaver_switch_unique_id( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test switch unique ID generation.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data with custom device ID - mock_status = MagicMock() - mock_status.device_id = "TEST_DEVICE_ID" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_screensaver = MagicMock() - mock_screensaver.disabled = False - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - } - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Check that the switch entity has the correct unique ID - entity_registry = er.async_get(hass) - entity = entity_registry.async_get("switch.kiosker_test_dev_disable_screensaver") - assert entity is not None - assert entity.unique_id == "TEST_DEVICE_ID_disable_screensaver" From 5964bcbd5f7561812dea331894fb5d3056130f23 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 25 Feb 2026 01:13:08 +0100 Subject: [PATCH 19/69] Review changes --- homeassistant/components/kiosker/__init__.py | 25 +----- .../components/kiosker/config_flow.py | 88 ++++++------------- .../components/kiosker/coordinator.py | 55 +++++++----- homeassistant/components/kiosker/entity.py | 6 +- .../components/kiosker/manifest.json | 2 +- homeassistant/components/kiosker/sensor.py | 46 +++++----- homeassistant/components/kiosker/strings.json | 74 ++++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 128 insertions(+), 172 deletions(-) diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 2b588ea9ff208..c9e173f077845 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -2,15 +2,11 @@ from __future__ import annotations -from kiosker import KioskerAPI - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_API_TOKEN, CONF_SSL, CONF_SSL_VERIFY -from .coordinator import KioskerDataUpdateCoordinator +from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator _PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -18,25 +14,11 @@ PARALLEL_UPDATES = 1 -type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: """Set up Kiosker from a config entry.""" - if KioskerAPI is None: - raise ConfigEntryNotReady("Kiosker dependency not available") - - api = KioskerAPI( - host=entry.data[CONF_HOST], - port=entry.data[CONF_PORT], - token=entry.data[CONF_API_TOKEN], - ssl=entry.data.get(CONF_SSL, False), - verify=entry.data.get(CONF_SSL_VERIFY, False), - ) coordinator = KioskerDataUpdateCoordinator( hass, - api, entry, ) @@ -49,9 +31,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) - # Start the polling cycle immediately to avoid initial delay - await coordinator.async_refresh() - return True diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 28c6050ff785b..e0dac021c45e2 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -35,12 +35,20 @@ vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, } ) +STEP_ZEROCONF_CONFIRM_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, + } +) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + Returns title and device_id for config entry setup. """ api = KioskerAPI( host=data[CONF_HOST], @@ -56,23 +64,24 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except (OSError, TimeoutError) as exc: _LOGGER.error("Failed to connect to Kiosker: %s", exc) raise CannotConnect from exc - except Exception as exc: - _LOGGER.error("Unexpected error connecting to Kiosker: %s", exc) + except (ValueError, TypeError) as exc: + _LOGGER.error("Invalid configuration data: %s", exc) raise CannotConnect from exc - # Return info that you want to store in the config entry - device_id = status.device_id if hasattr(status, "device_id") else data[CONF_HOST] + # Ensure we have a device_id from the status response + if not hasattr(status, "device_id") or not status.device_id: + _LOGGER.error("Device did not return a valid device_id") + raise CannotConnect + + device_id = status.device_id # Use first 8 characters of device_id for consistency with entity naming display_id = device_id[:8] if len(device_id) > 8 else device_id - return {"title": f"Kiosker {display_id}"} + return {"title": f"Kiosker {display_id}", "device_id": device_id} -class ConfigFlow(HAConfigFlow, domain=DOMAIN): +class KioskerConfigFlow(HAConfigFlow, domain=DOMAIN): """Handle a config flow for Kiosker.""" - VERSION = 1 - MINOR_VERSION = 1 - def __init__(self) -> None: """Initialize the config flow.""" super().__init__() @@ -91,47 +100,14 @@ async def async_step_user( info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except (ValueError, TypeError) as exc: - _LOGGER.error("Invalid configuration data: %s", exc) - errors["base"] = "invalid_host" except Exception: _LOGGER.exception("Unexpected exception during validation") errors["base"] = "unknown" else: - # Get device info to determine unique ID - api = KioskerAPI( - host=user_input[CONF_HOST], - port=user_input[CONF_PORT], - token=user_input[CONF_API_TOKEN], - ssl=user_input[CONF_SSL], - verify=user_input[CONF_SSL_VERIFY], - ) - try: - status = await self.hass.async_add_executor_job(api.status) - device_id = ( - status.device_id - if hasattr(status, "device_id") - else f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" - ) - except ( - OSError, - TimeoutError, - AttributeError, - ValueError, - TypeError, - KeyError, - ) as exc: - _LOGGER.debug("Could not get device ID from status: %s", exc) - device_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" - except Exception as exc: # noqa: BLE001 - # Broad exception in config flow for robustness during device discovery - _LOGGER.debug( - "Unexpected error getting device ID from status: %s", exc - ) - device_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" - # Use device ID as unique identifier - await self.async_set_unique_id(device_id, raise_on_progress=False) + await self.async_set_unique_id( + info["device_id"], raise_on_progress=False + ) self._abort_if_unique_id_configured( updates={ CONF_HOST: user_input[CONF_HOST], @@ -158,13 +134,13 @@ async def async_step_zeroconf( app_name = properties.get("app", "Kiosker") version = properties.get("version", "") - # Use UUID from zeroconf if available, otherwise use host:port as fallback + # Use UUID from zeroconf if uuid: device_name = f"{app_name} ({uuid[:8].upper()})" unique_id = uuid else: - device_name = f"{app_name} {host}" - unique_id = f"{host}:{port}" + _LOGGER.error("Device did not return a valid device_id") + raise CannotConnect # Set unique ID and check for duplicates await self.async_set_unique_id(unique_id) @@ -213,27 +189,13 @@ async def async_step_zeroconf_confirm( info = await validate_input(self.hass, config_data) except CannotConnect: errors[CONF_API_TOKEN] = "cannot_connect" - except (ValueError, TypeError) as exc: - _LOGGER.error("Invalid discovery data: %s", exc) - errors[CONF_API_TOKEN] = "invalid_auth" - except AttributeError as exc: - _LOGGER.error("Invalid discovery data structure: %s", exc) - errors["base"] = "unknown" else: return self.async_create_entry(title=info["title"], data=config_data) # Show form to get API token for discovered device - discovery_schema = vol.Schema( - { - vol.Required(CONF_API_TOKEN): str, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, - vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, - } - ) - return self.async_show_form( step_id="zeroconf_confirm", - data_schema=discovery_schema, + data_schema=STEP_ZEROCONF_CONFIRM_DATA_SCHEMA, description_placeholders=self.context["title_placeholders"], errors=errors, ) diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index 9d69519c17a2f..c1c960234aaf8 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -2,33 +2,50 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any -from kiosker import KioskerAPI +from kiosker import Blackout, KioskerAPI, ScreensaverState, Status from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, POLL_INTERVAL +from .const import CONF_API_TOKEN, CONF_SSL, CONF_SSL_VERIFY, DOMAIN, POLL_INTERVAL _LOGGER = logging.getLogger(__name__) +type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] -class KioskerDataUpdateCoordinator(DataUpdateCoordinator): + +@dataclass +class KioskerData: + """Data structure for Kiosker integration.""" + + status: Status + blackout: Blackout + screensaver: ScreensaverState + + +class KioskerDataUpdateCoordinator(DataUpdateCoordinator[KioskerData]): """Class to manage fetching data from the Kiosker API.""" def __init__( self, hass: HomeAssistant, - api: KioskerAPI, - config_entry: ConfigEntry, + config_entry: KioskerConfigEntry, ) -> None: """Initialize.""" - self.api = api + self.api = KioskerAPI( + host=config_entry.data[CONF_HOST], + port=config_entry.data[CONF_PORT], + token=config_entry.data[CONF_API_TOKEN], + ssl=config_entry.data.get(CONF_SSL, False), + verify=config_entry.data.get(CONF_SSL_VERIFY, False), + ) super().__init__( hass, _LOGGER, @@ -37,7 +54,7 @@ def __init__( config_entry=config_entry, ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> KioskerData: """Update data via library.""" try: status = await self.hass.async_add_executor_job(self.api.status) @@ -49,23 +66,21 @@ async def _async_update_data(self) -> dict[str, Any]: _LOGGER.warning( "Connection failed for Kiosker: %s", exception, exc_info=True ) - raise UpdateFailed(exception) from exception + raise UpdateFailed("Connection failed") from exception except Exception as exception: # Check if this is an authentication error (401) if self._is_auth_error(exception): _LOGGER.warning("Authentication failed for Kiosker: %s", exception) - raise ConfigEntryAuthFailed("Authentication failed") from exception + raise ConfigEntryError("Authentication failed") from exception - _LOGGER.warning( - "Failed to update Kiosker data: %s", exception, exc_info=True - ) - raise UpdateFailed(exception) from exception + _LOGGER.debug("Failed to update Kiosker data: %s", exception, exc_info=True) + raise UpdateFailed("Unknown error") from exception else: - return { - "status": status, - "blackout": blackout, - "screensaver": screensaver, - } + return KioskerData( + status=status, + blackout=blackout, + screensaver=screensaver, + ) def _is_auth_error(self, exception: Exception) -> bool: """Check if exception indicates authentication failure.""" diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index cb8647f45a256..508170de18ef2 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -40,10 +40,10 @@ def _get_status_attribute(self, attribute: str, default: str = "Unknown") -> str """Get attribute from coordinator status data.""" if ( self.coordinator.data - and "status" in self.coordinator.data - and hasattr(self.coordinator.data["status"], attribute) + and self.coordinator.data.status + and hasattr(self.coordinator.data.status, attribute) ): - return getattr(self.coordinator.data["status"], attribute) + return getattr(self.coordinator.data.status, attribute) return default def _get_device_id(self) -> str: diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index a2968ab5232a0..98fff607cadef 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/kiosker", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["kiosker-python-api==1.2.6"], + "requirements": ["kiosker-python-api==1.2.7"], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index 2fa7c07f182e2..13490adc89096 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -40,7 +40,7 @@ def parse_datetime(value: Any) -> datetime | None: return value try: return datetime.fromisoformat(str(value)) - except (ValueError, TypeError): + except ValueError, TypeError: return None @@ -64,18 +64,20 @@ def parse_datetime(value: Any) -> datetime | None: translation_key="last_interaction", icon="mdi:gesture-tap", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: parse_datetime(x.last_interaction) - if hasattr(x, "last_interaction") - else None, + value_fn=lambda x: ( + parse_datetime(x.last_interaction) + if hasattr(x, "last_interaction") + else None + ), ), KioskerSensorEntityDescription( key="lastMotion", translation_key="last_motion", icon="mdi:motion-sensor", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: parse_datetime(x.last_motion) - if hasattr(x, "last_motion") - else None, + value_fn=lambda x: ( + parse_datetime(x.last_motion) if hasattr(x, "last_motion") else None + ), ), KioskerSensorEntityDescription( key="ambientLight", @@ -89,23 +91,23 @@ def parse_datetime(value: Any) -> datetime | None: translation_key="last_update", icon="mdi:update", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: parse_datetime(x.last_update) - if hasattr(x, "last_update") - else None, + value_fn=lambda x: ( + parse_datetime(x.last_update) if hasattr(x, "last_update") else None + ), ), KioskerSensorEntityDescription( key="blackoutState", translation_key="blackout_state", icon="mdi:monitor-off", - value_fn=lambda x: "active" if x is not None else "inactive", + value_fn=lambda x: "active" if x is not None and x.visible else "inactive", ), KioskerSensorEntityDescription( key="screensaverVisibility", translation_key="screensaver_visibility", icon="mdi:power-sleep", - value_fn=lambda x: "visible" - if hasattr(x, "visible") and x.visible - else "hidden", + value_fn=lambda x: ( + "visible" if hasattr(x, "visible") and x.visible else "hidden" + ), ), ) @@ -152,16 +154,16 @@ def _handle_coordinator_update(self) -> None: if self.entity_description.key == "blackoutState": # Special handling for blackout state blackout_data = ( - self.coordinator.data.get("blackout") if self.coordinator.data else None + self.coordinator.data.blackout if self.coordinator.data else None ) if self.entity_description.value_fn: value = self.entity_description.value_fn(blackout_data) # Add all blackout data as extra attributes - if blackout_data is not None and hasattr(blackout_data, "__dict__"): + if blackout_data is not None: self._attr_extra_state_attributes = { - key: value - for key, value in blackout_data.__dict__.items() + key: getattr(blackout_data, key) + for key in blackout_data.__dataclass_fields__ if not key.startswith("_") } else: @@ -169,17 +171,15 @@ def _handle_coordinator_update(self) -> None: elif self.entity_description.key == "screensaverVisibility": # Special handling for screensaver visibility screensaver_data = ( - self.coordinator.data.get("screensaver") - if self.coordinator.data - else None + self.coordinator.data.screensaver if self.coordinator.data else None ) if self.entity_description.value_fn: value = self.entity_description.value_fn(screensaver_data) # Clear extra attributes for screensaver sensor self._attr_extra_state_attributes = {} - elif self.coordinator.data and "status" in self.coordinator.data: + elif self.coordinator.data: # Handle status-based sensors - status = self.coordinator.data["status"] + status = self.coordinator.data.status if self.entity_description.value_fn: value = self.entity_description.value_fn(status) # Clear extra attributes for non-blackout sensors diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index d5073c0c48dd0..807cd4d14ded0 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -1,45 +1,52 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "Failed to connect to Kiosker device.", + "invalid_auth": "Authentication failed.", + "invalid_host": "Invalid host or network configuration.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, "step": { - "user": { - "title": "Pair Kiosker App", - "description": "Enable the API in Kiosker settings to pair with Home Assistant.", + "discovery_confirm": { "data": { - "host": "Host", - "port": "Port", "api_token": "API Token", "ssl": "Use SSL", "ssl_verify": "Verify certificate" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", - "host": "The hostname or IP address of the device running the Kiosker App", - "port": "The port on which the Kiosker App is running. Default is 8081.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." - } - }, - "zeroconf": { - "description": "Do you want to configure {name} at {host}:{port}?" + }, + "description": "Pair `{name}` at `{host}:{port}`", + "title": "Pair Kiosker App" }, - "zeroconf_confirm": { - "title": "Discovered Kiosker App", - "description": "You are about to pair `{name}` at `{host}:{port}` with Home Assistant.\n\nPlease provide the API token to complete setup.", - "submit": "Pair", + "user": { "data": { "api_token": "API Token", + "host": "Host", + "port": "Port", "ssl": "Use SSL", "ssl_verify": "Verify certificate" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", + "host": "The hostname or IP address of the device running the Kiosker App", + "port": "The port on which the Kiosker App is running. Default is 8081.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." - } + }, + "description": "Enable the API in Kiosker settings to pair with Home Assistant.", + "title": "Pair Kiosker App" }, - "discovery_confirm": { - "title": "Pair Kiosker App", - "description": "Pair `{name}` at `{host}:{port}`", + "zeroconf": { + "description": "Do you want to configure {name} at {host}:{port}?" + }, + "zeroconf_confirm": { "data": { "api_token": "API Token", "ssl": "Use SSL", @@ -49,31 +56,27 @@ "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." - } + }, + "description": "You are about to pair `{name}` at `{host}:{port}` with Home Assistant.\n\nPlease provide the API token to complete setup.", + "submit": "Pair", + "title": "Discovered Kiosker App" } - }, - "error": { - "cannot_connect": "Failed to connect to Kiosker device.", - "invalid_auth": "Authentication failed.", - "invalid_host": "Invalid host or network configuration.", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" } }, "entity": { "sensor": { + "ambient_light": { + "name": "Ambient Light" + }, "battery_level": { "name": "Battery level" }, "battery_state": { "name": "Battery state", "state": { - "unknown": "Unknown", + "fully_charged": "Fully Charged", "not_charging": "Not Charging", - "fully_charged": "Fully Charged" + "unknown": "Unknown" } }, "blackout_state": { @@ -89,17 +92,14 @@ "last_motion": { "name": "Last motion" }, - "ambient_light": { - "name": "Ambient Light" - }, "last_update": { "name": "Last update" }, "screensaver_visibility": { "name": "Screensaver visibility", "state": { - "visible": "Visible", - "hidden": "Hidden" + "hidden": "Hidden", + "visible": "Visible" } } } diff --git a/requirements_all.txt b/requirements_all.txt index baa3e98b7f0a6..1a0e8bf5a2e9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1368,7 +1368,7 @@ keba-kecontact==1.3.0 kegtron-ble==1.0.2 # homeassistant.components.kiosker -kiosker-python-api==1.2.6 +kiosker-python-api==1.2.7 # homeassistant.components.kiwi kiwiki-client==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5517ff0730b2e..0cc42dd33fdce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1208,7 +1208,7 @@ justnimbus==0.7.4 kegtron-ble==1.0.2 # homeassistant.components.kiosker -kiosker-python-api==1.2.6 +kiosker-python-api==1.2.7 # homeassistant.components.knocki knocki==0.4.2 From c2de064037bdec5df6d04f9ed83b90d1cac1664f Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 26 Feb 2026 00:06:31 +0100 Subject: [PATCH 20/69] Review changes --- homeassistant/components/kiosker/__init__.py | 7 -- .../components/kiosker/coordinator.py | 18 ----- homeassistant/components/kiosker/entity.py | 77 +++++++++---------- homeassistant/components/kiosker/icons.json | 28 ++++++- homeassistant/components/kiosker/sensor.py | 42 +++------- 5 files changed, 74 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index c9e173f077845..8d8118975a020 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -4,15 +4,11 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator _PLATFORMS: list[Platform] = [Platform.SENSOR] -# Limit concurrent updates to prevent overwhelming the API -PARALLEL_UPDATES = 1 - async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: """Set up Kiosker from a config entry.""" @@ -24,9 +20,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> b await coordinator.async_config_entry_first_refresh() - if not coordinator.last_update_success: - raise ConfigEntryNotReady - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index c1c960234aaf8..7bc7c0698a349 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -11,7 +11,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_API_TOKEN, CONF_SSL, CONF_SSL_VERIFY, DOMAIN, POLL_INTERVAL @@ -68,11 +67,6 @@ async def _async_update_data(self) -> KioskerData: ) raise UpdateFailed("Connection failed") from exception except Exception as exception: - # Check if this is an authentication error (401) - if self._is_auth_error(exception): - _LOGGER.warning("Authentication failed for Kiosker: %s", exception) - raise ConfigEntryError("Authentication failed") from exception - _LOGGER.debug("Failed to update Kiosker data: %s", exception, exc_info=True) raise UpdateFailed("Unknown error") from exception else: @@ -81,15 +75,3 @@ async def _async_update_data(self) -> KioskerData: blackout=blackout, screensaver=screensaver, ) - - def _is_auth_error(self, exception: Exception) -> bool: - """Check if exception indicates authentication failure.""" - error_str = str(exception).lower() - # Check for common HTTP 401/authentication error patterns - return ( - "401" in error_str - or "unauthorized" in error_str - or "authentication" in error_str - or "invalid token" in error_str - or "access denied" in error_str - ) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 508170de18ef2..67ba6c7081b34 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -14,54 +16,47 @@ class KioskerEntity(CoordinatorEntity[KioskerDataUpdateCoordinator]): _attr_has_entity_name = True - def __init__(self, coordinator: KioskerDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: KioskerDataUpdateCoordinator, + description: Any | None = None, + ) -> None: """Initialize the entity.""" super().__init__(coordinator) - # Get device info with fallbacks for translation detection - device_id = self._get_device_id() - model = self._get_model() - hw_version = self._get_hw_version() - sw_version = self._get_sw_version() - app_name = self._get_app_name() - - # Ensure device info is always created, even without coordinator data + if description: + self.entity_description = description + + # Use coordinator data if available, otherwise fallback to config entry data + if coordinator.data and coordinator.data.status: + status = coordinator.data.status + device_id = status.device_id + model = status.model + app_name = status.app_name + app_version = status.app_version + os_version = status.os_version + else: + # Fallback when no data is available yet + device_id = "unknown" + model = "Unknown" + app_name = "Kiosker" + app_version = "Unknown" + os_version = "Unknown" + + # Use truncated device ID for consistency between device name and unique IDs + device_id_short = device_id[:8].lower() if device_id != "unknown" else "unknown" + + # Set device info self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, - name=f"Kiosker {device_id[:8]}" if device_id != "unknown" else "Kiosker", + name=f"Kiosker {device_id_short}" if device_id != "unknown" else "Kiosker", manufacturer="Top North", model=app_name, - sw_version=sw_version, - hw_version=f"{model} ({hw_version})", + sw_version=app_version, + hw_version=f"{model} ({os_version})", serial_number=device_id, ) - def _get_status_attribute(self, attribute: str, default: str = "Unknown") -> str: - """Get attribute from coordinator status data.""" - if ( - self.coordinator.data - and self.coordinator.data.status - and hasattr(self.coordinator.data.status, attribute) - ): - return getattr(self.coordinator.data.status, attribute) - return default - - def _get_device_id(self) -> str: - """Get device ID from coordinator data.""" - return self._get_status_attribute("device_id", "unknown") - - def _get_app_name(self) -> str: - """Get app name from coordinator data.""" - return self._get_status_attribute("app_name") - - def _get_model(self) -> str: - """Get model from coordinator data.""" - return self._get_status_attribute("model") - - def _get_sw_version(self) -> str: - """Get software version from coordinator data.""" - return self._get_status_attribute("app_version") - - def _get_hw_version(self) -> str: - """Get hardware version from coordinator data.""" - return self._get_status_attribute("os_version") + # Set unique ID if description is provided - use truncated ID to match device name + if description and hasattr(description, "translation_key"): + self._attr_unique_id = f"{device_id_short}_{description.translation_key}" diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index 0967ef424bce6..7a1ec2cc90b3b 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -1 +1,27 @@ -{} +{ + "entity": { + "sensor": { + "ambient_light": { + "default": "mdi:brightness-6" + }, + "battery_state": { + "default": "mdi:lightning-bolt" + }, + "blackout_state": { + "default": "mdi:monitor-off" + }, + "last_interaction": { + "default": "mdi:gesture-tap" + }, + "last_motion": { + "default": "mdi:motion-sensor" + }, + "last_update": { + "default": "mdi:update" + }, + "screensaver_visibility": { + "default": "mdi:power-sleep" + } + } + } +} diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index 13490adc89096..089d9134b8025 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -22,6 +22,7 @@ from .coordinator import KioskerDataUpdateCoordinator from .entity import KioskerEntity +# Limit concurrent updates to prevent overwhelming the API PARALLEL_UPDATES = 3 @@ -51,49 +52,36 @@ def parse_datetime(value: Any) -> datetime | None: device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.battery_level if hasattr(x, "battery_level") else None, + value_fn=lambda x: x.battery_level, ), KioskerSensorEntityDescription( key="batteryState", translation_key="battery_state", - icon="mdi:lightning-bolt", - value_fn=lambda x: x.battery_state if hasattr(x, "battery_state") else None, + value_fn=lambda x: x.battery_state, ), KioskerSensorEntityDescription( key="lastInteraction", translation_key="last_interaction", - icon="mdi:gesture-tap", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: ( - parse_datetime(x.last_interaction) - if hasattr(x, "last_interaction") - else None - ), + value_fn=lambda x: parse_datetime(x.last_interaction), ), KioskerSensorEntityDescription( key="lastMotion", translation_key="last_motion", - icon="mdi:motion-sensor", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: ( - parse_datetime(x.last_motion) if hasattr(x, "last_motion") else None - ), + value_fn=lambda x: parse_datetime(x.last_motion), ), KioskerSensorEntityDescription( key="ambientLight", translation_key="ambient_light", - icon="mdi:brightness-6", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.ambient_light if hasattr(x, "ambient_light") else None, + value_fn=lambda x: x.ambient_light, ), KioskerSensorEntityDescription( key="lastUpdate", translation_key="last_update", - icon="mdi:update", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: ( - parse_datetime(x.last_update) if hasattr(x, "last_update") else None - ), + value_fn=lambda x: parse_datetime(x.last_update), ), KioskerSensorEntityDescription( key="blackoutState", @@ -104,7 +92,6 @@ def parse_datetime(value: Any) -> datetime | None: KioskerSensorEntityDescription( key="screensaverVisibility", translation_key="screensaver_visibility", - icon="mdi:power-sleep", value_fn=lambda x: ( "visible" if hasattr(x, "visible") and x.visible else "hidden" ), @@ -121,9 +108,9 @@ async def async_setup_entry( coordinator = entry.runtime_data # Create all sensors - they will handle missing data gracefully - sensors = [KioskerSensor(coordinator, description) for description in SENSORS] - - async_add_entities(sensors) + async_add_entities( + KioskerSensor(coordinator, description) for description in SENSORS + ) class KioskerSensor(KioskerEntity, SensorEntity): @@ -137,14 +124,7 @@ def __init__( description: KioskerSensorEntityDescription, ) -> None: """Initialize the sensor entity.""" - - self.entity_description = description - - super().__init__(coordinator) - - # Get device ID properly for unique_id - device_id = self._get_device_id() - self._attr_unique_id = f"{device_id}_{description.translation_key}" + super().__init__(coordinator, description) @callback def _handle_coordinator_update(self) -> None: From 1f281f916e450d22e182565e3e788993392442e7 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 26 Feb 2026 22:31:29 +0100 Subject: [PATCH 21/69] Enhance Kiosker integration with improved error handling and updated dependencies --- .claude/agents/ha-code-inspector.md | 52 +++++++++++++++++++ .../components/kiosker/config_flow.py | 49 ++++++++++++++++- .../components/kiosker/coordinator.py | 52 +++++++++++++------ .../components/kiosker/manifest.json | 2 +- homeassistant/components/kiosker/strings.json | 7 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 .claude/agents/ha-code-inspector.md diff --git a/.claude/agents/ha-code-inspector.md b/.claude/agents/ha-code-inspector.md new file mode 100644 index 0000000000000..da6d4972150ba --- /dev/null +++ b/.claude/agents/ha-code-inspector.md @@ -0,0 +1,52 @@ +--- +name: ha-code-inspector +description: Use this agent when you need expert guidance on Home Assistant development best practices, code review, or implementation patterns. Examples: Context: User has written a new sensor entity and wants to ensure it follows HA best practices. user: 'I just implemented a new temperature sensor entity. Can you review it for best practices?' assistant: 'I'll use the ha-code-inspector agent to review your sensor implementation against Home Assistant best practices and coding standards.' The user is asking for code review of a Home Assistant component, so use the ha-code-inspector agent to provide expert guidance on HA-specific patterns and standards. Context: User is implementing error handling in their integration and wants to know the correct approach. user: 'What's the proper way to handle API timeouts in my Home Assistant integration?' assistant: 'Let me use the ha-code-inspector agent to provide guidance on proper error handling patterns for Home Assistant integrations.' The user needs expert advice on HA-specific error handling patterns, which is exactly what the ha-code-inspector agent specializes in. +model: sonnet +color: orange +--- + +You are a Home Assistant development expert with deep knowledge of the platform's architecture, coding standards, and best practices. You specialize in code inspection, pattern recognition, and providing guidance that aligns with Home Assistant's official development guidelines. + +Your core responsibilities: + +**Code Review & Analysis:** +- Review code against Home Assistant's strict coding standards from CLAUDE.md +- Identify anti-patterns and suggest proper implementations +- Focus on async programming, error handling, type hints, and entity patterns +- Check for proper use of coordinators, config entries, and entity lifecycle +- Verify compliance with Python 3.13+ features and typing requirements + +**Best Practice Guidance:** +- Recommend appropriate entity types and implementation patterns +- Guide proper use of Home Assistant APIs and frameworks +- Suggest correct error handling with specific exception types (ConfigEntryNotReady, ServiceValidationError, etc.) +- Advise on proper async patterns and thread safety +- Ensure logging follows HA guidelines (no periods, lazy logging, unavailability patterns) + +**Documentation & Reference:** +- Reference official Home Assistant developer documentation +- Compare implementations with existing core integrations +- Identify similar integrations that demonstrate best practices +- Provide specific examples from the codebase when relevant + +**Quality Assurance:** +- Verify adherence to formatting and linting standards (ruff, pylint, mypy) +- Check for proper test patterns and fixtures +- Ensure translation keys and user-facing messages follow guidelines +- Validate security practices (credential handling, data redaction) + +**When reviewing code:** +- Do NOT comment on missing imports or basic formatting (covered by tooling) +- Focus on architectural patterns, async safety, and HA-specific conventions +- Provide specific, actionable recommendations with code examples +- Reference relevant sections of Home Assistant documentation +- Suggest similar integrations to study for complex implementations + +**Output format:** +- Lead with a brief assessment of overall code quality +- Organize feedback by category (Architecture, Error Handling, Async Patterns, etc.) +- Provide specific code examples for recommended changes +- Include references to documentation or similar integrations +- End with a prioritized action plan for improvements + +Always maintain the friendly, informative tone specified in the guidelines while being thorough and technically precise in your analysis. diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index e0dac021c45e2..cae43e47b8bd4 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -5,7 +5,15 @@ import logging from typing import Any -from kiosker import KioskerAPI +from kiosker import ( + AuthenticationError, + BadRequestError, + ConnectionError, + IPAuthenticationError, + KioskerAPI, + PingError, + TLSVerificationError, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow as HAConfigFlow, ConfigFlowResult @@ -61,6 +69,21 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: # Test connection by getting status status = await hass.async_add_executor_job(api.status) + except ConnectionError as exc: + _LOGGER.error("Failed to connect to Kiosker: %s", exc) + raise CannotConnect from exc + except (AuthenticationError, IPAuthenticationError) as exc: + _LOGGER.error("Authentication failed: %s", exc) + raise InvalidAuth from exc + except TLSVerificationError as exc: + _LOGGER.error("TLS verification failed: %s", exc) + raise TLSError from exc + except BadRequestError as exc: + _LOGGER.error("Bad request: %s", exc) + raise BadRequest from exc + except PingError as exc: + _LOGGER.error("Ping failed: %s", exc) + raise CannotConnect from exc except (OSError, TimeoutError) as exc: _LOGGER.error("Failed to connect to Kiosker: %s", exc) raise CannotConnect from exc @@ -100,6 +123,12 @@ async def async_step_user( info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except TLSError: + errors["base"] = "tls_error" + except BadRequest: + errors["base"] = "bad_request" except Exception: _LOGGER.exception("Unexpected exception during validation") errors["base"] = "unknown" @@ -189,6 +218,12 @@ async def async_step_zeroconf_confirm( info = await validate_input(self.hass, config_data) except CannotConnect: errors[CONF_API_TOKEN] = "cannot_connect" + except InvalidAuth: + errors[CONF_API_TOKEN] = "invalid_auth" + except TLSError: + errors["base"] = "tls_error" + except BadRequest: + errors["base"] = "bad_request" else: return self.async_create_entry(title=info["title"], data=config_data) @@ -203,3 +238,15 @@ async def async_step_zeroconf_confirm( class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class TLSError(HomeAssistantError): + """Error to indicate TLS verification failed.""" + + +class BadRequest(HomeAssistantError): + """Error to indicate bad request.""" diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index 7bc7c0698a349..3f13f306ca503 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -6,11 +6,23 @@ from datetime import timedelta import logging -from kiosker import Blackout, KioskerAPI, ScreensaverState, Status +from kiosker import ( + AuthenticationError, + BadRequestError, + Blackout, + ConnectionError, + IPAuthenticationError, + KioskerAPI, + PingError, + ScreensaverState, + Status, + TLSVerificationError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_API_TOKEN, CONF_SSL, CONF_SSL_VERIFY, DOMAIN, POLL_INTERVAL @@ -61,17 +73,27 @@ async def _async_update_data(self) -> KioskerData: screensaver = await self.hass.async_add_executor_job( self.api.screensaver_get_state ) - except (OSError, TimeoutError) as exception: - _LOGGER.warning( - "Connection failed for Kiosker: %s", exception, exc_info=True - ) - raise UpdateFailed("Connection failed") from exception - except Exception as exception: - _LOGGER.debug("Failed to update Kiosker data: %s", exception, exc_info=True) - raise UpdateFailed("Unknown error") from exception - else: - return KioskerData( - status=status, - blackout=blackout, - screensaver=screensaver, - ) + except (AuthenticationError, IPAuthenticationError) as exc: + _LOGGER.error("Authentication failed: %s", exc) + raise ConfigEntryAuthFailed("Authentication failed") from exc + except (ConnectionError, PingError) as exc: + _LOGGER.debug("Connection failed: %s", exc) + raise UpdateFailed(f"Connection failed: {exc}") from exc + except TLSVerificationError as exc: + _LOGGER.debug("TLS verification failed: %s", exc) + raise UpdateFailed(f"TLS verification failed: {exc}") from exc + except BadRequestError as exc: + _LOGGER.warning("Bad request to Kiosker API: %s", exc) + raise UpdateFailed(f"Bad request: {exc}") from exc + except (OSError, TimeoutError) as exc: + _LOGGER.debug("Connection timeout or OS error: %s", exc) + raise UpdateFailed(f"Connection timeout: {exc}") from exc + except Exception as exc: + _LOGGER.exception("Unexpected error updating Kiosker data") + raise UpdateFailed(f"Unexpected error: {exc}") from exc + + return KioskerData( + status=status, + blackout=blackout, + screensaver=screensaver, + ) diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index 98fff607cadef..9076676c8624b 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/kiosker", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["kiosker-python-api==1.2.7"], + "requirements": ["kiosker-python-api==1.2.9"], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 807cd4d14ded0..24148392a9642 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -5,9 +5,10 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" }, "error": { - "cannot_connect": "Failed to connect to Kiosker device.", - "invalid_auth": "Authentication failed.", - "invalid_host": "Invalid host or network configuration.", + "bad_request": "Invalid request. Check your configuration.", + "cannot_connect": "Failed to connect to the Kiosker device.", + "invalid_auth": "Authentication failed. Check your API token.", + "tls_error": "TLS verification failed. Check your SSL settings.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { diff --git a/requirements_all.txt b/requirements_all.txt index 1a0e8bf5a2e9c..3dc30d9952610 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1368,7 +1368,7 @@ keba-kecontact==1.3.0 kegtron-ble==1.0.2 # homeassistant.components.kiosker -kiosker-python-api==1.2.7 +kiosker-python-api==1.2.9 # homeassistant.components.kiwi kiwiki-client==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0cc42dd33fdce..f4cd9070d49de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1208,7 +1208,7 @@ justnimbus==0.7.4 kegtron-ble==1.0.2 # homeassistant.components.kiosker -kiosker-python-api==1.2.7 +kiosker-python-api==1.2.9 # homeassistant.components.knocki knocki==0.4.2 From 0c793dd425d2ae7be9c006e65228833fa8f55fed Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sun, 1 Mar 2026 23:33:50 +0100 Subject: [PATCH 22/69] Removed agent --- .claude/agents/ha-code-inspector.md | 52 ----------------------------- 1 file changed, 52 deletions(-) delete mode 100644 .claude/agents/ha-code-inspector.md diff --git a/.claude/agents/ha-code-inspector.md b/.claude/agents/ha-code-inspector.md deleted file mode 100644 index da6d4972150ba..0000000000000 --- a/.claude/agents/ha-code-inspector.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: ha-code-inspector -description: Use this agent when you need expert guidance on Home Assistant development best practices, code review, or implementation patterns. Examples: Context: User has written a new sensor entity and wants to ensure it follows HA best practices. user: 'I just implemented a new temperature sensor entity. Can you review it for best practices?' assistant: 'I'll use the ha-code-inspector agent to review your sensor implementation against Home Assistant best practices and coding standards.' The user is asking for code review of a Home Assistant component, so use the ha-code-inspector agent to provide expert guidance on HA-specific patterns and standards. Context: User is implementing error handling in their integration and wants to know the correct approach. user: 'What's the proper way to handle API timeouts in my Home Assistant integration?' assistant: 'Let me use the ha-code-inspector agent to provide guidance on proper error handling patterns for Home Assistant integrations.' The user needs expert advice on HA-specific error handling patterns, which is exactly what the ha-code-inspector agent specializes in. -model: sonnet -color: orange ---- - -You are a Home Assistant development expert with deep knowledge of the platform's architecture, coding standards, and best practices. You specialize in code inspection, pattern recognition, and providing guidance that aligns with Home Assistant's official development guidelines. - -Your core responsibilities: - -**Code Review & Analysis:** -- Review code against Home Assistant's strict coding standards from CLAUDE.md -- Identify anti-patterns and suggest proper implementations -- Focus on async programming, error handling, type hints, and entity patterns -- Check for proper use of coordinators, config entries, and entity lifecycle -- Verify compliance with Python 3.13+ features and typing requirements - -**Best Practice Guidance:** -- Recommend appropriate entity types and implementation patterns -- Guide proper use of Home Assistant APIs and frameworks -- Suggest correct error handling with specific exception types (ConfigEntryNotReady, ServiceValidationError, etc.) -- Advise on proper async patterns and thread safety -- Ensure logging follows HA guidelines (no periods, lazy logging, unavailability patterns) - -**Documentation & Reference:** -- Reference official Home Assistant developer documentation -- Compare implementations with existing core integrations -- Identify similar integrations that demonstrate best practices -- Provide specific examples from the codebase when relevant - -**Quality Assurance:** -- Verify adherence to formatting and linting standards (ruff, pylint, mypy) -- Check for proper test patterns and fixtures -- Ensure translation keys and user-facing messages follow guidelines -- Validate security practices (credential handling, data redaction) - -**When reviewing code:** -- Do NOT comment on missing imports or basic formatting (covered by tooling) -- Focus on architectural patterns, async safety, and HA-specific conventions -- Provide specific, actionable recommendations with code examples -- Reference relevant sections of Home Assistant documentation -- Suggest similar integrations to study for complex implementations - -**Output format:** -- Lead with a brief assessment of overall code quality -- Organize feedback by category (Architecture, Error Handling, Async Patterns, etc.) -- Provide specific code examples for recommended changes -- Include references to documentation or similar integrations -- End with a prioritized action plan for improvements - -Always maintain the friendly, informative tone specified in the guidelines while being thorough and technically precise in your analysis. From 092f6c05e21e368ce68d129baaa411fe79b44df6 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:39:13 +0100 Subject: [PATCH 23/69] Update homeassistant/components/kiosker/config_flow.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/kiosker/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index cae43e47b8bd4..1ee2466df1174 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -105,6 +105,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, class KioskerConfigFlow(HAConfigFlow, domain=DOMAIN): """Handle a config flow for Kiosker.""" + VERSION = 1 + MINOR_VERSION = 1 def __init__(self) -> None: """Initialize the config flow.""" super().__init__() From fa0e4bf9a3f04351cfcb4309668aa499fd51e643 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:39:47 +0100 Subject: [PATCH 24/69] Update homeassistant/components/kiosker/.gitignore Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/kiosker/.gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/kiosker/.gitignore b/homeassistant/components/kiosker/.gitignore index 976fae24ab7c8..e69de29bb2d1d 100644 --- a/homeassistant/components/kiosker/.gitignore +++ b/homeassistant/components/kiosker/.gitignore @@ -1,2 +0,0 @@ -claude.md -.claude/ \ No newline at end of file From de78917ef5e421b9eebb706b6c0d6e2908c0cf78 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:40:23 +0100 Subject: [PATCH 25/69] Update homeassistant/components/kiosker/coordinator.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/kiosker/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index 3f13f306ca503..b0c49a2e61881 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -37,8 +37,8 @@ class KioskerData: """Data structure for Kiosker integration.""" status: Status - blackout: Blackout - screensaver: ScreensaverState + blackout: Blackout | None + screensaver: ScreensaverState | None class KioskerDataUpdateCoordinator(DataUpdateCoordinator[KioskerData]): From 4e631cc34a5431a9e2e5f7e10b0540907481571f Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:41:52 +0100 Subject: [PATCH 26/69] Update homeassistant/components/kiosker/entity.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/kiosker/entity.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 67ba6c7081b34..6c808cf1f2045 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -43,13 +43,21 @@ def __init__( app_version = "Unknown" os_version = "Unknown" - # Use truncated device ID for consistency between device name and unique IDs + # Use truncated device ID for consistency in unique IDs device_id_short = device_id[:8].lower() if device_id != "unknown" else "unknown" + # Use uppercased truncated device ID for display purposes (device name, titles) + device_id_short_display = ( + device_id[:8].upper() if device_id != "unknown" else "unknown" + ) # Set device info self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, - name=f"Kiosker {device_id_short}" if device_id != "unknown" else "Kiosker", + name=( + f"Kiosker {device_id_short_display}" + if device_id != "unknown" + else "Kiosker" + ), manufacturer="Top North", model=app_name, sw_version=app_version, From 20e4b35b3786a1eefbdeddc27db892a436032541 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:43:44 +0100 Subject: [PATCH 27/69] Update homeassistant/components/kiosker/entity.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/kiosker/entity.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 6c808cf1f2045..f62fd477dbb38 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -65,6 +65,10 @@ def __init__( serial_number=device_id, ) - # Set unique ID if description is provided - use truncated ID to match device name - if description and hasattr(description, "translation_key"): - self._attr_unique_id = f"{device_id_short}_{description.translation_key}" + # Set unique ID if description is provided - use full device ID for uniqueness + if ( + description + and hasattr(description, "translation_key") + and device_id != "unknown" + ): + self._attr_unique_id = f"{device_id}_{description.translation_key}" From 6efcecc0f1a9e978e4eb060e23a2357277cef020 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 2 Mar 2026 00:21:56 +0100 Subject: [PATCH 28/69] Updated tests for sensor --- .../components/kiosker/config_flow.py | 1 + homeassistant/components/kiosker/entity.py | 2 - tests/components/kiosker/test_config_flow.py | 54 ++-- tests/components/kiosker/test_init.py | 35 ++- tests/components/kiosker/test_sensor.py | 292 +++++++++++------- 5 files changed, 231 insertions(+), 153 deletions(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 1ee2466df1174..6df1b36274346 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -107,6 +107,7 @@ class KioskerConfigFlow(HAConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + def __init__(self) -> None: """Initialize the config flow.""" super().__init__() diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index f62fd477dbb38..48e6eb0c488c2 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -43,8 +43,6 @@ def __init__( app_version = "Unknown" os_version = "Unknown" - # Use truncated device ID for consistency in unique IDs - device_id_short = device_id[:8].lower() if device_id != "unknown" else "unknown" # Use uppercased truncated device ID for display purposes (device name, titles) device_id_short_display = ( device_id[:8].upper() if device_id != "unknown" else "unknown" diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 39c8948163995..100da9a8574e7 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -3,6 +3,7 @@ from ipaddress import ip_address from unittest.mock import Mock, patch +from kiosker import ConnectionError import pytest from homeassistant import config_entries @@ -65,7 +66,10 @@ async def test_form(hass: HomeAssistant) -> None: mock_api.status.return_value = mock_status mock_api_class.return_value = mock_api - mock_validate.return_value = {"title": "Kiosker A98BE1CE"} + mock_validate.return_value = { + "title": "Kiosker A98BE1CE", + "device_id": "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", + } result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -159,20 +163,13 @@ async def test_zeroconf(hass: HomeAssistant) -> None: async def test_zeroconf_no_uuid(hass: HomeAssistant) -> None: - """Test zeroconf discovery without UUID.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=DISCOVERY_INFO_NO_UUID, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "zeroconf_confirm" - # Check description placeholders instead of context - assert result["description_placeholders"] == { - "name": "Kiosker 192.168.1.39", - "host": "192.168.1.39", - "port": "8081", - } + """Test zeroconf discovery without UUID raises CannotConnect.""" + with pytest.raises(CannotConnect): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO_NO_UUID, + ) async def test_zeroconf_confirm(hass: HomeAssistant) -> None: @@ -293,7 +290,10 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: mock_api.status.return_value = mock_status mock_api_class.return_value = mock_api - mock_validate.return_value = {"title": "Kiosker A98BE1CE"} + mock_validate.return_value = { + "title": "Kiosker A98BE1CE", + "device_id": "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", + } result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -330,16 +330,16 @@ async def test_zeroconf_abort_if_already_configured(hass: HomeAssistant) -> None async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None: - """Test manual setup falls back to host:port when device_id unavailable.""" + """Test manual setup raises CannotConnect when device_id unavailable.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with ( patch( - "homeassistant.components.kiosker.config_flow.validate_input" - ) as mock_validate, - patch("homeassistant.components.kiosker.async_setup_entry", return_value=True), + "homeassistant.components.kiosker.config_flow.validate_input", + side_effect=CannotConnect, + ), patch( "homeassistant.components.kiosker.config_flow.KioskerAPI" ) as mock_api_class, @@ -349,8 +349,6 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None mock_api.status.side_effect = Exception("Connection failed") mock_api_class.return_value = mock_api - mock_validate.return_value = {"title": "Kiosker 192.168.1.100"} - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -361,10 +359,9 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None "ssl_verify": False, }, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Kiosker 192.168.1.100" + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} async def test_validate_input_success( @@ -385,7 +382,10 @@ async def test_validate_input_success( } result = await validate_input(hass, data) - assert result == {"title": "Kiosker A98BE1CE"} + assert result == { + "title": "Kiosker A98BE1CE", + "device_id": "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", + } async def test_validate_input_connection_error( @@ -395,7 +395,7 @@ async def test_validate_input_connection_error( ) -> None: """Test validate_input with connection error.""" - mock_kiosker_api.status.side_effect = Exception("Connection failed") + mock_kiosker_api.status.side_effect = ConnectionError("Connection failed") mock_kiosker_api_class.return_value = mock_kiosker_api data = { diff --git a/tests/components/kiosker/test_init.py b/tests/components/kiosker/test_init.py index c2d9b7c7b711b..0af142837c762 100644 --- a/tests/components/kiosker/test_init.py +++ b/tests/components/kiosker/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch from homeassistant.components.kiosker.const import DOMAIN +from homeassistant.components.kiosker.coordinator import KioskerData from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -16,7 +17,9 @@ async def test_async_setup_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test a successful setup entry and unload.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -35,7 +38,11 @@ async def test_async_setup_entry( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = {"status": mock_status} + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -50,7 +57,9 @@ async def test_async_setup_entry_failure( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test an unsuccessful setup entry.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API that fails mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -70,7 +79,9 @@ async def test_device_info( device_registry: dr.DeviceRegistry, ) -> None: """Test device registry integration.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -89,7 +100,11 @@ async def test_device_info( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = {"status": mock_status} + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) await setup_integration(hass, mock_config_entry) @@ -112,7 +127,9 @@ async def test_device_identifiers_and_info( device_registry: dr.DeviceRegistry, ) -> None: """Test device identifiers and device info are set correctly.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -131,7 +148,11 @@ async def test_device_identifiers_and_info( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = {"status": mock_status} + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) await setup_integration(hass, mock_config_entry) diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index 7c75d9d5b5770..8b2ee60f9f1f0 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -5,6 +5,7 @@ from datetime import datetime from unittest.mock import MagicMock, patch +from homeassistant.components.kiosker.coordinator import KioskerData from homeassistant.components.kiosker.sensor import parse_datetime from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -16,7 +17,9 @@ async def test_sensors_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test setting up all sensors.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -41,6 +44,11 @@ async def test_sensors_setup( mock_blackout = MagicMock() mock_blackout.visible = True mock_blackout.text = "Test blackout" + # Mock dataclass fields for extra attributes + mock_blackout.__dataclass_fields__ = { + "visible": None, + "text": None, + } mock_api.status.return_value = mock_status mock_api.screensaver_get_state.return_value = mock_screensaver @@ -53,11 +61,11 @@ async def test_sensors_setup( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - "blackout": mock_blackout, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=mock_blackout, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -89,7 +97,9 @@ async def test_battery_level_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test battery level sensor.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -117,18 +127,22 @@ async def test_battery_level_sensor( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -145,7 +159,9 @@ async def test_battery_state_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test battery state sensor.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -173,18 +189,22 @@ async def test_battery_state_sensor( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -192,14 +212,15 @@ async def test_battery_state_sensor( state = hass.states.get("sensor.kiosker_a98be1ce_battery_state") assert state is not None assert state.state == "discharging" - assert state.attributes["icon"] == "mdi:lightning-bolt" async def test_last_interaction_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test last interaction sensor.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -227,18 +248,22 @@ async def test_last_interaction_sensor( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -247,14 +272,15 @@ async def test_last_interaction_sensor( assert state is not None assert state.state == "2025-01-01T12:00:00+00:00" assert state.attributes["device_class"] == "timestamp" - assert state.attributes["icon"] == "mdi:gesture-tap" async def test_last_motion_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test last motion sensor.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -282,18 +308,22 @@ async def test_last_motion_sensor( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -302,14 +332,15 @@ async def test_last_motion_sensor( assert state is not None assert state.state == "2025-01-01T11:55:00+00:00" assert state.attributes["device_class"] == "timestamp" - assert state.attributes["icon"] == "mdi:motion-sensor" async def test_last_update_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test last update sensor.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -337,18 +368,22 @@ async def test_last_update_sensor( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -357,14 +392,15 @@ async def test_last_update_sensor( assert state is not None assert state.state == "2025-01-01T12:05:00+00:00" assert state.attributes["device_class"] == "timestamp" - assert state.attributes["icon"] == "mdi:update" async def test_ambient_light_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test ambient light sensor.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -393,18 +429,22 @@ async def test_ambient_light_sensor( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -412,7 +452,6 @@ async def test_ambient_light_sensor( state = hass.states.get("sensor.kiosker_a98be1ce_ambient_light") assert state is not None assert state.state == "2.6" - assert state.attributes["icon"] == "mdi:brightness-6" assert state.attributes["state_class"] == "measurement" # Verify no unit of measurement (unit-less sensor) assert "unit_of_measurement" not in state.attributes @@ -422,7 +461,9 @@ async def test_blackout_state_sensor_active( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test blackout state sensor when blackout is active.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -447,6 +488,13 @@ async def test_blackout_state_sensor_active( mock_blackout.text = "Test blackout message" mock_blackout.background = "#000000" mock_blackout.foreground = "#FFFFFF" + # Mock dataclass fields for extra attributes + mock_blackout.__dataclass_fields__ = { + "visible": None, + "text": None, + "background": None, + "foreground": None, + } mock_api.status.return_value = mock_status mock_api.blackout_get.return_value = mock_blackout @@ -458,20 +506,22 @@ async def test_blackout_state_sensor_active( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - "blackout": mock_blackout, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=mock_blackout, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - "blackout": mock_blackout, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=mock_blackout, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -491,7 +541,9 @@ async def test_blackout_state_sensor_inactive( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test blackout state sensor when blackout is inactive.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -520,20 +572,22 @@ async def test_blackout_state_sensor_inactive( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - # No blackout key - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - # No blackout key - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -548,7 +602,9 @@ async def test_screensaver_visibility_sensor_visible( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test screensaver visibility sensor when screensaver is visible.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -581,20 +637,22 @@ async def test_screensaver_visibility_sensor_visible( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - "screensaver": mock_screensaver, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -602,14 +660,15 @@ async def test_screensaver_visibility_sensor_visible( state = hass.states.get("sensor.kiosker_a98be1ce_screensaver_visibility") assert state is not None assert state.state == "visible" - assert state.attributes["icon"] == "mdi:power-sleep" async def test_screensaver_visibility_sensor_hidden( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test screensaver visibility sensor when screensaver is hidden.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -642,20 +701,22 @@ async def test_screensaver_visibility_sensor_hidden( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - "screensaver": mock_screensaver, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - "screensaver": mock_screensaver, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() @@ -663,14 +724,15 @@ async def test_screensaver_visibility_sensor_hidden( state = hass.states.get("sensor.kiosker_a98be1ce_screensaver_visibility") assert state is not None assert state.state == "hidden" - assert state.attributes["icon"] == "mdi:power-sleep" async def test_sensors_missing_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test sensors when data is missing.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -684,37 +746,27 @@ async def test_sensors_missing_data( mock_status.app_name = "Kiosker" mock_status.app_version = "25.1.1" - # Explicitly remove attributes that should be missing - del mock_status.battery_level - del mock_status.battery_state - del mock_status.last_interaction - del mock_status.last_motion - del mock_status.ambient_light - del mock_status.last_update - mock_api.status.return_value = mock_status # Add the config entry mock_config_entry.add_to_hass(hass) - # Setup the integration with missing data + # Setup the integration with missing data (None coordinator data) with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - # No screensaver or blackout data - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - # Manually set coordinator data and trigger update + # Test missing data by setting coordinator data to None coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - # No screensaver or blackout data - } + coordinator.data = None coordinator.async_update_listeners() await hass.async_block_till_done() @@ -750,7 +802,9 @@ async def test_sensor_unique_ids( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test sensor unique ID generation.""" - with patch("homeassistant.components.kiosker.KioskerAPI") as mock_api_class: + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: # Setup mock API mock_api = MagicMock() mock_api.host = "10.0.1.5" @@ -778,18 +832,22 @@ async def test_sensor_unique_ids( with patch( "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" ) as mock_update: - mock_update.return_value = { - "status": mock_status, - } + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Manually set coordinator data and trigger update coordinator = mock_config_entry.runtime_data - coordinator.data = { - "status": mock_status, - } + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) coordinator.async_update_listeners() await hass.async_block_till_done() From b1f161925bdfe334d0e499fa9482f3aaf8039093 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 2 Mar 2026 00:27:01 +0100 Subject: [PATCH 29/69] Updated translations --- homeassistant/components/kiosker/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 24148392a9642..1ea23aeb80fe2 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -75,6 +75,7 @@ "battery_state": { "name": "Battery state", "state": { + "charging": "Charging", "fully_charged": "Fully Charged", "not_charging": "Not Charging", "unknown": "Unknown" From 1ec92e3d2937d330b5cc2deccea73ac2e07b5ddb Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 2 Mar 2026 00:29:26 +0100 Subject: [PATCH 30/69] Updated tests --- tests/components/kiosker/test_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index 8b2ee60f9f1f0..d7d59f6e770cd 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -175,7 +175,7 @@ async def test_battery_state_sensor( mock_status.app_name = "Kiosker" mock_status.app_version = "25.1.1" mock_status.battery_level = 85 - mock_status.battery_state = "discharging" + mock_status.battery_state = "charging" mock_status.last_interaction = "2025-01-01T12:00:00Z" mock_status.last_motion = "2025-01-01T11:55:00Z" mock_status.last_update = "2025-01-01T12:05:00Z" @@ -211,7 +211,7 @@ async def test_battery_state_sensor( # Check battery state sensor state = hass.states.get("sensor.kiosker_a98be1ce_battery_state") assert state is not None - assert state.state == "discharging" + assert state.state == "charging" async def test_last_interaction_sensor( From b9b0054283089df4bba8a16ee9b102f505982363 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 2 Mar 2026 23:02:04 +0100 Subject: [PATCH 31/69] Copilot Review --- .../components/kiosker/config_flow.py | 41 ++++++++----------- homeassistant/components/kiosker/const.py | 3 +- .../components/kiosker/coordinator.py | 13 ++---- .../components/kiosker/manifest.json | 1 + homeassistant/components/kiosker/strings.json | 24 +++-------- homeassistant/generated/integrations.json | 2 +- 6 files changed, 30 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 6df1b36274346..4b558dc43c4b8 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -17,20 +17,12 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow as HAConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import ( - CONF_API_TOKEN, - CONF_SSL, - CONF_SSL_VERIFY, - DEFAULT_PORT, - DEFAULT_SSL, - DEFAULT_SSL_VERIFY, - DOMAIN, -) +from .const import CONF_API_TOKEN, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -40,14 +32,14 @@ vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_API_TOKEN): str, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, - vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, } ) STEP_ZEROCONF_CONFIRM_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_TOKEN): str, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, - vol.Optional(CONF_SSL_VERIFY, default=DEFAULT_SSL_VERIFY): bool, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, } ) @@ -63,32 +55,35 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, port=data[CONF_PORT], token=data[CONF_API_TOKEN], ssl=data[CONF_SSL], - verify=data[CONF_SSL_VERIFY], + verify=data[CONF_VERIFY_SSL], ) try: # Test connection by getting status status = await hass.async_add_executor_job(api.status) except ConnectionError as exc: - _LOGGER.error("Failed to connect to Kiosker: %s", exc) + _LOGGER.debug("Failed to connect", exc_info=True) raise CannotConnect from exc except (AuthenticationError, IPAuthenticationError) as exc: - _LOGGER.error("Authentication failed: %s", exc) + _LOGGER.debug("Authentication failed", exc_info=True) raise InvalidAuth from exc except TLSVerificationError as exc: - _LOGGER.error("TLS verification failed: %s", exc) + _LOGGER.debug("TLS verification failed", exc_info=True) raise TLSError from exc except BadRequestError as exc: - _LOGGER.error("Bad request: %s", exc) + _LOGGER.debug("Bad request", exc_info=True) raise BadRequest from exc except PingError as exc: - _LOGGER.error("Ping failed: %s", exc) + _LOGGER.debug("Ping failed", exc_info=True) raise CannotConnect from exc except (OSError, TimeoutError) as exc: - _LOGGER.error("Failed to connect to Kiosker: %s", exc) + _LOGGER.debug("Failed to connect", exc_info=True) raise CannotConnect from exc except (ValueError, TypeError) as exc: - _LOGGER.error("Invalid configuration data: %s", exc) + _LOGGER.debug("Invalid configuration data", exc_info=True) + raise CannotConnect from exc + except Exception as exc: + _LOGGER.exception("Unexpected exception while connecting to Kiosker") raise CannotConnect from exc # Ensure we have a device_id from the status response @@ -171,8 +166,8 @@ async def async_step_zeroconf( device_name = f"{app_name} ({uuid[:8].upper()})" unique_id = uuid else: - _LOGGER.error("Device did not return a valid device_id") - raise CannotConnect + _LOGGER.warning("Device did not return a valid device_id") + return self.async_abort(reason="cannot_connect") # Set unique ID and check for duplicates await self.async_set_unique_id(unique_id) @@ -214,7 +209,7 @@ async def async_step_zeroconf_confirm( CONF_PORT: port, CONF_API_TOKEN: user_input[CONF_API_TOKEN], CONF_SSL: user_input.get(CONF_SSL, DEFAULT_SSL), - CONF_SSL_VERIFY: user_input.get(CONF_SSL_VERIFY, DEFAULT_SSL_VERIFY), + CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL, DEFAULT_SSL_VERIFY), } try: diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py index 6240dcc3c1492..c54d0a99bbcca 100644 --- a/homeassistant/components/kiosker/const.py +++ b/homeassistant/components/kiosker/const.py @@ -4,8 +4,7 @@ # Configuration keys CONF_API_TOKEN = "api_token" -CONF_SSL = "ssl" -CONF_SSL_VERIFY = "ssl_verify" + # Default values DEFAULT_PORT = 8081 POLL_INTERVAL = 15 diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index b0c49a2e61881..15fc41e7d6978 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -20,12 +20,12 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_API_TOKEN, CONF_SSL, CONF_SSL_VERIFY, DOMAIN, POLL_INTERVAL +from .const import CONF_API_TOKEN, DOMAIN, POLL_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -55,7 +55,7 @@ def __init__( port=config_entry.data[CONF_PORT], token=config_entry.data[CONF_API_TOKEN], ssl=config_entry.data.get(CONF_SSL, False), - verify=config_entry.data.get(CONF_SSL_VERIFY, False), + verify=config_entry.data.get(CONF_VERIFY_SSL, False), ) super().__init__( hass, @@ -74,22 +74,17 @@ async def _async_update_data(self) -> KioskerData: self.api.screensaver_get_state ) except (AuthenticationError, IPAuthenticationError) as exc: - _LOGGER.error("Authentication failed: %s", exc) raise ConfigEntryAuthFailed("Authentication failed") from exc except (ConnectionError, PingError) as exc: - _LOGGER.debug("Connection failed: %s", exc) raise UpdateFailed(f"Connection failed: {exc}") from exc except TLSVerificationError as exc: - _LOGGER.debug("TLS verification failed: %s", exc) raise UpdateFailed(f"TLS verification failed: {exc}") from exc except BadRequestError as exc: - _LOGGER.warning("Bad request to Kiosker API: %s", exc) raise UpdateFailed(f"Bad request: {exc}") from exc except (OSError, TimeoutError) as exc: - _LOGGER.debug("Connection timeout or OS error: %s", exc) raise UpdateFailed(f"Connection timeout: {exc}") from exc except Exception as exc: - _LOGGER.exception("Unexpected error updating Kiosker data") + _LOGGER.debug("Unexpected error updating Kiosker data") raise UpdateFailed(f"Unexpected error: {exc}") from exc return KioskerData( diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index 9076676c8624b..fc8c2ed911faa 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Claeysson"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kiosker", + "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", "requirements": ["kiosker-python-api==1.2.9"], diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 1ea23aeb80fe2..1ff527f10a4b1 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -12,34 +12,20 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { - "discovery_confirm": { - "data": { - "api_token": "API Token", - "ssl": "Use SSL", - "ssl_verify": "Verify certificate" - }, - "data_description": { - "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", - "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", - "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." - }, - "description": "Pair `{name}` at `{host}:{port}`", - "title": "Pair Kiosker App" - }, "user": { "data": { "api_token": "API Token", "host": "Host", "port": "Port", "ssl": "Use SSL", - "ssl_verify": "Verify certificate" + "verify_ssl": "Verify certificate" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", "host": "The hostname or IP address of the device running the Kiosker App", "port": "The port on which the Kiosker App is running. Default is 8081.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", - "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." + "verify_ssl": "Verify SSL certificate. Enable for valid certificates only." }, "description": "Enable the API in Kiosker settings to pair with Home Assistant.", "title": "Pair Kiosker App" @@ -51,12 +37,12 @@ "data": { "api_token": "API Token", "ssl": "Use SSL", - "ssl_verify": "Verify certificate" + "verify_ssl": "Verify certificate" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", - "ssl_verify": "Verify SSL certificate. Enable for valid certificates only." + "verify_ssl": "Verify SSL certificate. Enable for valid certificates only." }, "description": "You are about to pair `{name}` at `{host}:{port}` with Home Assistant.\n\nPlease provide the API token to complete setup.", "submit": "Pair", @@ -67,7 +53,7 @@ "entity": { "sensor": { "ambient_light": { - "name": "Ambient Light" + "name": "Ambient light" }, "battery_level": { "name": "Battery level" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 05657cd629806..727e7a81eaae2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3427,7 +3427,7 @@ }, "kiosker": { "name": "Kiosker", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, From 735344dba6a9c64f6197c88a0d8cb49f2d020d0b Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Mon, 2 Mar 2026 23:09:36 +0100 Subject: [PATCH 32/69] Copilot Review --- tests/components/kiosker/test_config_flow.py | 101 ++++++++++--------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 100da9a8574e7..b0a860294b91e 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -8,8 +8,8 @@ from homeassistant import config_entries from homeassistant.components.kiosker.config_flow import CannotConnect, validate_input -from homeassistant.components.kiosker.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components.kiosker.const import CONF_API_TOKEN, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -76,9 +76,9 @@ async def test_form(hass: HomeAssistant) -> None: { CONF_HOST: "192.168.1.100", CONF_PORT: 8081, - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) await hass.async_block_till_done() @@ -88,9 +88,9 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { CONF_HOST: "192.168.1.100", CONF_PORT: 8081, - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } assert len(mock_setup_entry.mock_calls) == 1 @@ -110,9 +110,9 @@ async def test_form_invalid_host(hass: HomeAssistant) -> None: { CONF_HOST: "192.168.1.100", CONF_PORT: 8081, - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) @@ -135,9 +135,9 @@ async def test_form_unexpected_exception(hass: HomeAssistant) -> None: { CONF_HOST: "192.168.1.100", CONF_PORT: 8081, - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) @@ -163,13 +163,14 @@ async def test_zeroconf(hass: HomeAssistant) -> None: async def test_zeroconf_no_uuid(hass: HomeAssistant) -> None: - """Test zeroconf discovery without UUID raises CannotConnect.""" - with pytest.raises(CannotConnect): - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=DISCOVERY_INFO_NO_UUID, - ) + """Test zeroconf discovery without UUID aborts with cannot_connect.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO_NO_UUID, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" async def test_zeroconf_confirm(hass: HomeAssistant) -> None: @@ -187,7 +188,7 @@ async def test_zeroconf_confirm(hass: HomeAssistant) -> None: assert result_confirm["step_id"] == "zeroconf_confirm" # Check that the form includes API token field schema_keys = list(result_confirm["data_schema"].schema.keys()) - assert any(key.schema == "api_token" for key in schema_keys) + assert any(key.schema == CONF_API_TOKEN for key in schema_keys) async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: @@ -215,9 +216,9 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) await hass.async_block_till_done() @@ -227,9 +228,9 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: assert result3["data"] == { CONF_HOST: "192.168.1.39", CONF_PORT: 8081, - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } assert len(mock_setup_entry.mock_calls) == 1 @@ -253,21 +254,25 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"api_token": "cannot_connect"} + assert result3["errors"] == {CONF_API_TOKEN: "cannot_connect"} async def test_abort_if_already_configured(hass: HomeAssistant) -> None: """Test we abort if already configured.""" entry = MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "test_token"}, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 8081, + CONF_API_TOKEN: "test_token", + }, unique_id="A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", ) entry.add_to_hass(hass) @@ -300,9 +305,9 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: { CONF_HOST: "192.168.1.200", CONF_PORT: 8081, - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) @@ -314,7 +319,11 @@ async def test_zeroconf_abort_if_already_configured(hass: HomeAssistant) -> None """Test we abort zeroconf discovery if already configured.""" entry = MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: "192.168.1.100", CONF_PORT: 8081, "api_token": "test_token"}, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 8081, + CONF_API_TOKEN: "test_token", + }, unique_id="A98BE1CE-1234-1234-1234-123456789ABC", ) entry.add_to_hass(hass) @@ -354,9 +363,9 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None { CONF_HOST: "192.168.1.100", CONF_PORT: 8081, - "api_token": "test-token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, ) @@ -376,9 +385,9 @@ async def test_validate_input_success( data = { CONF_HOST: "10.0.1.5", CONF_PORT: 8081, - "api_token": "test_token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test_token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } result = await validate_input(hass, data) @@ -401,9 +410,9 @@ async def test_validate_input_connection_error( data = { CONF_HOST: "192.168.1.100", CONF_PORT: 8081, - "api_token": "test_token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test_token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, } with pytest.raises(CannotConnect): From e776c5e2a4f5aaaf23b40ce700ccdf66bfd9d306 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Tue, 3 Mar 2026 09:14:38 +0100 Subject: [PATCH 33/69] Remove .gitigonre in component --- homeassistant/components/kiosker/.gitignore | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 homeassistant/components/kiosker/.gitignore diff --git a/homeassistant/components/kiosker/.gitignore b/homeassistant/components/kiosker/.gitignore deleted file mode 100644 index e69de29bb2d1d..0000000000000 From dcc0589968a2a2a19dea8049b04dfdcc6513e6ff Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Tue, 3 Mar 2026 10:22:38 +0100 Subject: [PATCH 34/69] Copilot Review --- .../components/kiosker/config_flow.py | 7 ++---- tests/components/kiosker/conftest.py | 10 ++++---- tests/components/kiosker/test_config_flow.py | 25 ++++++------------- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 4b558dc43c4b8..3df99b822a57e 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -166,7 +166,7 @@ async def async_step_zeroconf( device_name = f"{app_name} ({uuid[:8].upper()})" unique_id = uuid else: - _LOGGER.warning("Device did not return a valid device_id") + _LOGGER.debug("Zeroconf properties did not include a valid device_id") return self.async_abort(reason="cannot_connect") # Set unique ID and check for duplicates @@ -187,10 +187,7 @@ async def async_step_zeroconf( self._discovered_version = version # Show confirmation dialog - return self.async_show_form( - step_id="zeroconf_confirm", - description_placeholders=self.context["title_placeholders"], - ) + return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/kiosker/conftest.py b/tests/components/kiosker/conftest.py index e2337a1ba2bdb..5af80062e9e14 100644 --- a/tests/components/kiosker/conftest.py +++ b/tests/components/kiosker/conftest.py @@ -7,8 +7,8 @@ import pytest -from homeassistant.components.kiosker.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components.kiosker.const import CONF_API_TOKEN, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from tests.common import MockConfigEntry @@ -31,9 +31,9 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_HOST: "10.0.1.5", CONF_PORT: 8081, - "api_token": "test_token", - "ssl": False, - "ssl_verify": False, + CONF_API_TOKEN: "test_token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, }, unique_id="A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", ) diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index b0a860294b91e..48746210948a1 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -182,7 +182,7 @@ async def test_zeroconf_confirm(hass: HomeAssistant) -> None: ) result_confirm = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + result["flow_id"], user_input=None ) assert result_confirm["type"] is FlowResultType.FORM assert result_confirm["step_id"] == "zeroconf_confirm" @@ -199,10 +199,6 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: data=DISCOVERY_INFO, ) - _result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - with ( patch( "homeassistant.components.kiosker.config_flow.validate_input" @@ -213,7 +209,7 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: ): mock_validate.return_value = {"title": "Kiosker Device"} - result3 = await hass.config_entries.flow.async_configure( + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_API_TOKEN: "test-token", @@ -223,9 +219,9 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Kiosker Device" - assert result3["data"] == { + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Kiosker Device" + assert result2["data"] == { CONF_HOST: "192.168.1.39", CONF_PORT: 8081, CONF_API_TOKEN: "test-token", @@ -243,15 +239,11 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> data=DISCOVERY_INFO, ) - _result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - with patch( "homeassistant.components.kiosker.config_flow.validate_input", side_effect=CannotConnect, ): - result3 = await hass.config_entries.flow.async_configure( + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_API_TOKEN: "test-token", @@ -259,9 +251,8 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> CONF_VERIFY_SSL: False, }, ) - - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {CONF_API_TOKEN: "cannot_connect"} + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_API_TOKEN: "cannot_connect"} async def test_abort_if_already_configured(hass: HomeAssistant) -> None: From 184244bca839e2ea88ee90185f921c613546db60 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 10:17:24 +0100 Subject: [PATCH 35/69] joostlek review --- homeassistant/components/kiosker/__init__.py | 7 +- .../components/kiosker/binary_sensor.py | 115 +++++ .../components/kiosker/config_flow.py | 34 +- .../components/kiosker/coordinator.py | 13 +- homeassistant/components/kiosker/entity.py | 29 +- homeassistant/components/kiosker/icons.json | 11 +- homeassistant/components/kiosker/sensor.py | 98 +--- homeassistant/components/kiosker/strings.json | 22 +- tests/components/kiosker/conftest.py | 7 +- .../components/kiosker/test_binary_sensor.py | 459 ++++++++++++++++++ tests/components/kiosker/test_config_flow.py | 8 +- tests/components/kiosker/test_sensor.py | 436 ++--------------- 12 files changed, 679 insertions(+), 560 deletions(-) create mode 100644 homeassistant/components/kiosker/binary_sensor.py create mode 100644 tests/components/kiosker/test_binary_sensor.py diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 8d8118975a020..21ba2bc5f2ef3 100644 --- a/homeassistant/components/kiosker/__init__.py +++ b/homeassistant/components/kiosker/__init__.py @@ -7,16 +7,13 @@ from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: """Set up Kiosker from a config entry.""" - coordinator = KioskerDataUpdateCoordinator( - hass, - entry, - ) + coordinator = KioskerDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/kiosker/binary_sensor.py b/homeassistant/components/kiosker/binary_sensor.py new file mode 100644 index 0000000000000..1f2ba8340e9c4 --- /dev/null +++ b/homeassistant/components/kiosker/binary_sensor.py @@ -0,0 +1,115 @@ +"""Support for Kiosker binary sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import KioskerConfigEntry +from .coordinator import KioskerDataUpdateCoordinator +from .entity import KioskerEntity + +# Limit concurrent updates to prevent overwhelming the API +PARALLEL_UPDATES = 3 + + +@dataclass(frozen=True, kw_only=True) +class KioskerBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Kiosker binary sensor entity.""" + + value_fn: Callable[[Any], bool] + + +BINARY_SENSORS: tuple[KioskerBinarySensorEntityDescription, ...] = ( + KioskerBinarySensorEntityDescription( + key="blackoutState", + translation_key="blackout_state", + value_fn=lambda x: hasattr(x, "visible") and x.visible, + ), + KioskerBinarySensorEntityDescription( + key="screensaverVisibility", + translation_key="screensaver_visibility", + value_fn=lambda x: hasattr(x, "visible") and x.visible, + ), + KioskerBinarySensorEntityDescription( + key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda x: ( + x.battery_state in ("Charging", "Fully Charged") + if hasattr(x, "battery_state") + else False + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KioskerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Kiosker binary sensors based on a config entry.""" + coordinator = entry.runtime_data + + # Create all binary sensors - they will handle missing data gracefully + async_add_entities( + KioskerBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) + + +class KioskerBinarySensor(KioskerEntity, BinarySensorEntity): + """Representation of a Kiosker binary sensor.""" + + entity_description: KioskerBinarySensorEntityDescription + + def __init__( + self, + coordinator: KioskerDataUpdateCoordinator, + description: KioskerBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor entity.""" + super().__init__(coordinator, description) + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + if not self.coordinator.data: + return None + + data_source = None + + if self.entity_description.key == "blackoutState": + data_source = self.coordinator.data.blackout + elif self.entity_description.key == "screensaverVisibility": + data_source = self.coordinator.data.screensaver + elif self.entity_description.key == "charging": + data_source = self.coordinator.data.status + + if data_source is not None: + return self.entity_description.value_fn(data_source) + return False + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return extra state attributes for blackout state sensor.""" + if not self.coordinator.data or self.entity_description.key != "blackoutState": + return None + + blackout_data = self.coordinator.data.blackout + if blackout_data is None: + return None + + return { + key: getattr(blackout_data, key) + for key in blackout_data.__dataclass_fields__ + if not key.startswith("_") + } diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 3df99b822a57e..93a391ce94a72 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -16,7 +16,7 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow as HAConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -38,7 +38,6 @@ STEP_ZEROCONF_CONFIRM_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_TOKEN): str, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, } ) @@ -62,25 +61,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, # Test connection by getting status status = await hass.async_add_executor_job(api.status) except ConnectionError as exc: - _LOGGER.debug("Failed to connect", exc_info=True) raise CannotConnect from exc except (AuthenticationError, IPAuthenticationError) as exc: - _LOGGER.debug("Authentication failed", exc_info=True) raise InvalidAuth from exc except TLSVerificationError as exc: - _LOGGER.debug("TLS verification failed", exc_info=True) raise TLSError from exc except BadRequestError as exc: - _LOGGER.debug("Bad request", exc_info=True) raise BadRequest from exc except PingError as exc: - _LOGGER.debug("Ping failed", exc_info=True) - raise CannotConnect from exc - except (OSError, TimeoutError) as exc: - _LOGGER.debug("Failed to connect", exc_info=True) - raise CannotConnect from exc - except (ValueError, TypeError) as exc: - _LOGGER.debug("Invalid configuration data", exc_info=True) raise CannotConnect from exc except Exception as exc: _LOGGER.exception("Unexpected exception while connecting to Kiosker") @@ -97,7 +85,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return {"title": f"Kiosker {display_id}", "device_id": device_id} -class KioskerConfigFlow(HAConfigFlow, domain=DOMAIN): +class KioskerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Kiosker.""" VERSION = 1 @@ -105,11 +93,12 @@ class KioskerConfigFlow(HAConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - super().__init__() + self._discovered_host: str | None = None self._discovered_port: int | None = None self._discovered_uuid: str | None = None self._discovered_version: str | None = None + self._discovered_ssl: bool | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -135,12 +124,7 @@ async def async_step_user( await self.async_set_unique_id( info["device_id"], raise_on_progress=False ) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - } - ) + self._abort_if_unique_id_configured() return self.async_create_entry(title=info["title"], data=user_input) @@ -160,6 +144,7 @@ async def async_step_zeroconf( uuid = properties.get("uuid") app_name = properties.get("app", "Kiosker") version = properties.get("version", "") + ssl = properties.get("ssl", "false").lower() == "true" # Use UUID from zeroconf if uuid: @@ -171,13 +156,14 @@ async def async_step_zeroconf( # Set unique ID and check for duplicates await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured(updates={CONF_HOST: host, CONF_PORT: port}) + self._abort_if_unique_id_configured() # Store discovery info for confirmation step self.context["title_placeholders"] = { "name": device_name, "host": host, "port": str(port), + "ssl": ssl, } # Store discovered information for later use @@ -185,6 +171,7 @@ async def async_step_zeroconf( self._discovered_port = port self._discovered_uuid = uuid self._discovered_version = version + self._discovered_ssl = ssl # Show confirmation dialog return await self.async_step_zeroconf_confirm() @@ -199,13 +186,14 @@ async def async_step_zeroconf_confirm( # Use stored discovery info and user-provided token host = self._discovered_host port = self._discovered_port + ssl = self._discovered_ssl # Create config with discovered host/port and user-provided token config_data = { CONF_HOST: host, CONF_PORT: port, CONF_API_TOKEN: user_input[CONF_API_TOKEN], - CONF_SSL: user_input.get(CONF_SSL, DEFAULT_SSL), + CONF_SSL: ssl, CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL, DEFAULT_SSL_VERIFY), } diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index 15fc41e7d6978..cc5d33b6cf1d6 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -65,13 +65,18 @@ def __init__( config_entry=config_entry, ) + def _fetch_all_data(self) -> tuple[Status, Blackout, ScreensaverState]: + """Fetch all data from the API in a single executor job.""" + status = self.api.status() + blackout = self.api.blackout_get() + screensaver = self.api.screensaver_get_state() + return status, blackout, screensaver + async def _async_update_data(self) -> KioskerData: """Update data via library.""" try: - status = await self.hass.async_add_executor_job(self.api.status) - blackout = await self.hass.async_add_executor_job(self.api.blackout_get) - screensaver = await self.hass.async_add_executor_job( - self.api.screensaver_get_state + status, blackout, screensaver = await self.hass.async_add_executor_job( + self._fetch_all_data ) except (AuthenticationError, IPAuthenticationError) as exc: raise ConfigEntryAuthFailed("Authentication failed") from exc diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 48e6eb0c488c2..617166213f735 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -2,9 +2,8 @@ from __future__ import annotations -from typing import Any - from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -19,7 +18,7 @@ class KioskerEntity(CoordinatorEntity[KioskerDataUpdateCoordinator]): def __init__( self, coordinator: KioskerDataUpdateCoordinator, - description: Any | None = None, + description: EntityDescription | None = None, ) -> None: """Initialize the entity.""" super().__init__(coordinator) @@ -37,15 +36,15 @@ def __init__( os_version = status.os_version else: # Fallback when no data is available yet - device_id = "unknown" - model = "Unknown" - app_name = "Kiosker" - app_version = "Unknown" - os_version = "Unknown" + device_id = None + model = None + app_name = None + app_version = None + os_version = None # Use uppercased truncated device ID for display purposes (device name, titles) device_id_short_display = ( - device_id[:8].upper() if device_id != "unknown" else "unknown" + device_id[:8].upper() if device_id != "unknown" else None ) # Set device info @@ -53,7 +52,7 @@ def __init__( identifiers={(DOMAIN, device_id)}, name=( f"Kiosker {device_id_short_display}" - if device_id != "unknown" + if device_id is not None else "Kiosker" ), manufacturer="Top North", @@ -63,10 +62,6 @@ def __init__( serial_number=device_id, ) - # Set unique ID if description is provided - use full device ID for uniqueness - if ( - description - and hasattr(description, "translation_key") - and device_id != "unknown" - ): - self._attr_unique_id = f"{device_id}_{description.translation_key}" + self._attr_unique_id = ( + f"{device_id}_{description.key}" if description else f"{device_id}" + ) diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index 7a1ec2cc90b3b..effd52fe6d1c9 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -1,5 +1,13 @@ { "entity": { + "binary_sensor": { + "blackout_state": { + "default": "mdi:monitor-off" + }, + "screensaver_visibility": { + "default": "mdi:power-sleep" + } + }, "sensor": { "ambient_light": { "default": "mdi:brightness-6" @@ -18,9 +26,6 @@ }, "last_update": { "default": "mdi:update" - }, - "screensaver_visibility": { - "default": "mdi:power-sleep" } } } diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index 089d9134b8025..807d7c1afd5bf 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -14,7 +14,7 @@ SensorStateClass, ) from homeassistant.const import PERCENTAGE -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -26,50 +26,32 @@ PARALLEL_UPDATES = 3 -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class KioskerSensorEntityDescription(SensorEntityDescription): """Kiosker sensor description.""" - value_fn: Callable[[Any], StateType | datetime] | None = None - - -def parse_datetime(value: Any) -> datetime | None: - """Parse datetime from various formats.""" - if value is None: - return None - if isinstance(value, datetime): - return value - try: - return datetime.fromisoformat(str(value)) - except ValueError, TypeError: - return None + value_fn: Callable[[Any], StateType | datetime] SENSORS: tuple[KioskerSensorEntityDescription, ...] = ( KioskerSensorEntityDescription( key="batteryLevel", - translation_key="battery_level", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: x.battery_level, ), - KioskerSensorEntityDescription( - key="batteryState", - translation_key="battery_state", - value_fn=lambda x: x.battery_state, - ), KioskerSensorEntityDescription( key="lastInteraction", translation_key="last_interaction", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: parse_datetime(x.last_interaction), + value_fn=lambda x: x.last_interaction, ), KioskerSensorEntityDescription( key="lastMotion", translation_key="last_motion", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: parse_datetime(x.last_motion), + value_fn=lambda x: x.last_motion, ), KioskerSensorEntityDescription( key="ambientLight", @@ -81,20 +63,7 @@ def parse_datetime(value: Any) -> datetime | None: key="lastUpdate", translation_key="last_update", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: parse_datetime(x.last_update), - ), - KioskerSensorEntityDescription( - key="blackoutState", - translation_key="blackout_state", - icon="mdi:monitor-off", - value_fn=lambda x: "active" if x is not None and x.visible else "inactive", - ), - KioskerSensorEntityDescription( - key="screensaverVisibility", - translation_key="screensaver_visibility", - value_fn=lambda x: ( - "visible" if hasattr(x, "visible") and x.visible else "hidden" - ), + value_fn=lambda x: x.last_update, ), ) @@ -126,47 +95,14 @@ def __init__( """Initialize the sensor entity.""" super().__init__(coordinator, description) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - value = None - - if self.entity_description.key == "blackoutState": - # Special handling for blackout state - blackout_data = ( - self.coordinator.data.blackout if self.coordinator.data else None - ) - if self.entity_description.value_fn: - value = self.entity_description.value_fn(blackout_data) - - # Add all blackout data as extra attributes - if blackout_data is not None: - self._attr_extra_state_attributes = { - key: getattr(blackout_data, key) - for key in blackout_data.__dataclass_fields__ - if not key.startswith("_") - } - else: - self._attr_extra_state_attributes = {} - elif self.entity_description.key == "screensaverVisibility": - # Special handling for screensaver visibility - screensaver_data = ( - self.coordinator.data.screensaver if self.coordinator.data else None - ) - if self.entity_description.value_fn: - value = self.entity_description.value_fn(screensaver_data) - # Clear extra attributes for screensaver sensor - self._attr_extra_state_attributes = {} - elif self.coordinator.data: - # Handle status-based sensors - status = self.coordinator.data.status - if self.entity_description.value_fn: - value = self.entity_description.value_fn(status) - # Clear extra attributes for non-blackout sensors - self._attr_extra_state_attributes = {} - else: - # Clear extra attributes if no data - self._attr_extra_state_attributes = {} - - self._attr_native_value = value - self.async_write_ha_state() + @property + def native_value(self) -> StateType | datetime | None: + """Return the native value of the sensor.""" + if not self.coordinator.data: + return None + + status = self.coordinator.data.status + if not status: + return None + + return self.entity_description.value_fn(status) diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 1ff527f10a4b1..dd6d9e3b702cf 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -51,6 +51,14 @@ } }, "entity": { + "binary_sensor": { + "blackout_state": { + "name": "Blackout" + }, + "screensaver_visibility": { + "name": "Screensaver" + } + }, "sensor": { "ambient_light": { "name": "Ambient light" @@ -67,13 +75,6 @@ "unknown": "Unknown" } }, - "blackout_state": { - "name": "Blackout state", - "state": { - "active": "Active", - "inactive": "Inactive" - } - }, "last_interaction": { "name": "Last interaction" }, @@ -82,13 +83,6 @@ }, "last_update": { "name": "Last update" - }, - "screensaver_visibility": { - "name": "Screensaver visibility", - "state": { - "hidden": "Hidden", - "visible": "Visible" - } } } } diff --git a/tests/components/kiosker/conftest.py b/tests/components/kiosker/conftest.py index 5af80062e9e14..335a78c32c131 100644 --- a/tests/components/kiosker/conftest.py +++ b/tests/components/kiosker/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Generator +from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -55,9 +56,9 @@ def mock_kiosker_api(): mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_api.status.return_value = mock_status diff --git a/tests/components/kiosker/test_binary_sensor.py b/tests/components/kiosker/test_binary_sensor.py new file mode 100644 index 0000000000000..10cbe76cc4c08 --- /dev/null +++ b/tests/components/kiosker/test_binary_sensor.py @@ -0,0 +1,459 @@ +"""Test the Kiosker binary sensors.""" + +from unittest.mock import MagicMock, patch + +from kiosker import Blackout, ScreensaverState + +from homeassistant.components.kiosker.coordinator import KioskerData +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_binary_sensors_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that binary sensor entities are created.""" + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_screensaver = ScreensaverState(visible=True, disabled=False) + mock_blackout = Blackout(visible=True) + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + mock_api.blackout_get.return_value = mock_blackout + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=mock_blackout, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check that binary sensor entities were created + expected_binary_sensors = [ + "binary_sensor.kiosker_a98be1ce_blackout", + "binary_sensor.kiosker_a98be1ce_screensaver", + "binary_sensor.kiosker_a98be1ce_charging", + ] + + for sensor_id in expected_binary_sensors: + state = hass.states.get(sensor_id) + assert state is not None, f"Binary sensor {sensor_id} was not created" + + +async def test_blackout_state_active( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test blackout state binary sensor when active.""" + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_blackout = Blackout(visible=True, text="Test blackout") + + mock_api.status.return_value = mock_status + mock_api.blackout_get.return_value = mock_blackout + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=mock_blackout, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually trigger coordinator update to get proper state + coordinator = mock_config_entry.runtime_data + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=mock_blackout, + ) + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check blackout state binary sensor + state = hass.states.get("binary_sensor.kiosker_a98be1ce_blackout") + assert state is not None + assert state.state == STATE_ON + + +async def test_blackout_state_inactive( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test blackout state binary sensor when inactive.""" + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_blackout = Blackout(visible=False) + + mock_api.status.return_value = mock_status + mock_api.blackout_get.return_value = mock_blackout + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=mock_blackout, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually trigger coordinator update to get proper state + coordinator = mock_config_entry.runtime_data + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=mock_blackout, + ) + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check blackout state binary sensor + state = hass.states.get("binary_sensor.kiosker_a98be1ce_blackout") + assert state is not None + assert state.state == STATE_OFF + + +async def test_screensaver_visible( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test screensaver visibility binary sensor when visible.""" + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_screensaver = ScreensaverState(visible=True, disabled=False) + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=None, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually trigger coordinator update to get proper state + coordinator = mock_config_entry.runtime_data + coordinator.data = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=None, + ) + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check screensaver visibility binary sensor + state = hass.states.get("binary_sensor.kiosker_a98be1ce_screensaver") + assert state is not None + assert state.state == STATE_ON + + +async def test_screensaver_hidden( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test screensaver visibility binary sensor when hidden.""" + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + + mock_screensaver = ScreensaverState(visible=False, disabled=False) + + mock_api.status.return_value = mock_status + mock_api.screensaver_get_state.return_value = mock_screensaver + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=None, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually trigger coordinator update to get proper state + coordinator = mock_config_entry.runtime_data + coordinator.data = KioskerData( + status=mock_status, + screensaver=mock_screensaver, + blackout=None, + ) + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check screensaver visibility binary sensor + state = hass.states.get("binary_sensor.kiosker_a98be1ce_screensaver") + assert state is not None + assert state.state == STATE_OFF + + +async def test_charging_binary_sensor_charging( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test charging binary sensor when battery is charging.""" + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_state = "Charging" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually trigger coordinator update to get proper state + coordinator = mock_config_entry.runtime_data + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check charging binary sensor + state = hass.states.get("binary_sensor.kiosker_a98be1ce_charging") + assert state is not None + assert state.state == STATE_ON + + +async def test_charging_binary_sensor_fully_charged( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test charging binary sensor when battery is fully charged.""" + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_state = "Fully Charged" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually trigger coordinator update to get proper state + coordinator = mock_config_entry.runtime_data + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check charging binary sensor + state = hass.states.get("binary_sensor.kiosker_a98be1ce_charging") + assert state is not None + assert state.state == STATE_ON + + +async def test_charging_binary_sensor_not_charging( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test charging binary sensor when battery is not charging.""" + with patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI" + ) as mock_api_class: + # Setup mock API + mock_api = MagicMock() + mock_api.host = "10.0.1.5" + mock_api_class.return_value = mock_api + + # Setup mock data + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_state = "not_charging" + + mock_api.status.return_value = mock_status + + # Add the config entry + mock_config_entry.add_to_hass(hass) + + # Setup the integration + with patch( + "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" + ) as mock_update: + mock_update.return_value = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Manually trigger coordinator update to get proper state + coordinator = mock_config_entry.runtime_data + coordinator.data = KioskerData( + status=mock_status, + screensaver=None, + blackout=None, + ) + coordinator.async_update_listeners() + await hass.async_block_till_done() + + # Check charging binary sensor + state = hass.states.get("binary_sensor.kiosker_a98be1ce_charging") + assert state is not None + assert state.state == STATE_OFF diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 48746210948a1..4fecc441c21bf 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -26,6 +26,7 @@ "uuid": "A98BE1CE-1234-1234-1234-123456789ABC", "app": "Kiosker", "version": "1.0.0", + "ssl": "true", }, type="_kiosker._tcp.local.", ) @@ -36,7 +37,7 @@ hostname="kiosker-device.local.", name="Kiosker Device._kiosker._tcp.local.", port=8081, - properties={"app": "Kiosker", "version": "1.0.0"}, + properties={"app": "Kiosker", "version": "1.0.0", "ssl": "false"}, type="_kiosker._tcp.local.", ) @@ -159,6 +160,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: "name": "Kiosker (A98BE1CE)", "host": "192.168.1.39", "port": "8081", + "ssl": True, } @@ -213,7 +215,6 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: result["flow_id"], { CONF_API_TOKEN: "test-token", - CONF_SSL: False, CONF_VERIFY_SSL: False, }, ) @@ -225,7 +226,7 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: CONF_HOST: "192.168.1.39", CONF_PORT: 8081, CONF_API_TOKEN: "test-token", - CONF_SSL: False, + CONF_SSL: True, CONF_VERIFY_SSL: False, } assert len(mock_setup_entry.mock_calls) == 1 @@ -247,7 +248,6 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> result["flow_id"], { CONF_API_TOKEN: "test-token", - CONF_SSL: False, CONF_VERIFY_SSL: False, }, ) diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index d7d59f6e770cd..67906d63f91eb 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock, patch from homeassistant.components.kiosker.coordinator import KioskerData -from homeassistant.components.kiosker.sensor import parse_datetime from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -34,9 +33,9 @@ async def test_sensors_setup( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_screensaver = MagicMock() mock_screensaver.visible = True @@ -72,14 +71,11 @@ async def test_sensors_setup( # Check that all sensor entities were created expected_sensors = [ - "sensor.kiosker_a98be1ce_battery_level", - "sensor.kiosker_a98be1ce_battery_state", + "sensor.kiosker_a98be1ce_battery", "sensor.kiosker_a98be1ce_last_interaction", "sensor.kiosker_a98be1ce_last_motion", "sensor.kiosker_a98be1ce_ambient_light", "sensor.kiosker_a98be1ce_last_update", - "sensor.kiosker_a98be1ce_blackout_state", - "sensor.kiosker_a98be1ce_screensaver_visibility", ] for sensor_id in expected_sensors: @@ -114,9 +110,9 @@ async def test_battery_level_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 42 mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_api.status.return_value = mock_status @@ -147,7 +143,7 @@ async def test_battery_level_sensor( await hass.async_block_till_done() # Check battery level sensor - state = hass.states.get("sensor.kiosker_a98be1ce_battery_level") + state = hass.states.get("sensor.kiosker_a98be1ce_battery") assert state is not None assert state.state == "42" assert state.attributes["unit_of_measurement"] == "%" @@ -155,65 +151,6 @@ async def test_battery_level_sensor( assert state.attributes["state_class"] == "measurement" -async def test_battery_state_sensor( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test battery state sensor.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check battery state sensor - state = hass.states.get("sensor.kiosker_a98be1ce_battery_state") - assert state is not None - assert state.state == "charging" - - async def test_last_interaction_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -235,9 +172,9 @@ async def test_last_interaction_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.last_interaction = datetime.fromisoformat("2026-03-03T22:41:09Z") + mock_status.last_motion = datetime.fromisoformat("2025-03-03T22:40:09Z") + mock_status.last_update = datetime.fromisoformat("2026-01-03T12:41:09Z") mock_api.status.return_value = mock_status @@ -270,7 +207,7 @@ async def test_last_interaction_sensor( # Check last interaction sensor state = hass.states.get("sensor.kiosker_a98be1ce_last_interaction") assert state is not None - assert state.state == "2025-01-01T12:00:00+00:00" + assert state.state == "2026-03-03T22:41:09+00:00" assert state.attributes["device_class"] == "timestamp" @@ -295,9 +232,9 @@ async def test_last_motion_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_api.status.return_value = mock_status @@ -355,9 +292,9 @@ async def test_last_update_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_api.status.return_value = mock_status @@ -415,9 +352,9 @@ async def test_ambient_light_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_status.ambient_light = 2.6 mock_api.status.return_value = mock_status @@ -457,275 +394,6 @@ async def test_ambient_light_sensor( assert "unit_of_measurement" not in state.attributes -async def test_blackout_state_sensor_active( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test blackout state sensor when blackout is active.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - # Setup blackout data - mock_blackout = MagicMock() - mock_blackout.visible = True - mock_blackout.text = "Test blackout message" - mock_blackout.background = "#000000" - mock_blackout.foreground = "#FFFFFF" - # Mock dataclass fields for extra attributes - mock_blackout.__dataclass_fields__ = { - "visible": None, - "text": None, - "background": None, - "foreground": None, - } - - mock_api.status.return_value = mock_status - mock_api.blackout_get.return_value = mock_blackout - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=mock_blackout, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=mock_blackout, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check blackout state sensor - state = hass.states.get("sensor.kiosker_a98be1ce_blackout_state") - assert state is not None - assert state.state == "active" - assert state.attributes["icon"] == "mdi:monitor-off" - # Check that blackout data is in extra attributes - assert "visible" in state.attributes - assert "text" in state.attributes - assert "background" in state.attributes - assert "foreground" in state.attributes - - -async def test_blackout_state_sensor_inactive( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test blackout state sensor when blackout is inactive.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - mock_api.status.return_value = mock_status - mock_api.blackout_get.return_value = None - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration with no blackout data - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check blackout state sensor - state = hass.states.get("sensor.kiosker_a98be1ce_blackout_state") - assert state is not None - assert state.state == "inactive" - assert state.attributes["icon"] == "mdi:monitor-off" - - -async def test_screensaver_visibility_sensor_visible( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test screensaver visibility sensor when screensaver is visible.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - # Setup screensaver data - mock_screensaver = MagicMock() - mock_screensaver.visible = True - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check screensaver visibility sensor - state = hass.states.get("sensor.kiosker_a98be1ce_screensaver_visibility") - assert state is not None - assert state.state == "visible" - - -async def test_screensaver_visibility_sensor_hidden( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test screensaver visibility sensor when screensaver is hidden.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" - - # Setup screensaver data - mock_screensaver = MagicMock() - mock_screensaver.visible = False - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check screensaver visibility sensor - state = hass.states.get("sensor.kiosker_a98be1ce_screensaver_visibility") - assert state is not None - assert state.state == "hidden" - - async def test_sensors_missing_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -772,8 +440,7 @@ async def test_sensors_missing_data( # Check that sensors handle missing data gracefully sensors_with_unknown_state = [ - "sensor.kiosker_a98be1ce_battery_level", - "sensor.kiosker_a98be1ce_battery_state", + "sensor.kiosker_a98be1ce_battery", "sensor.kiosker_a98be1ce_last_interaction", "sensor.kiosker_a98be1ce_last_motion", "sensor.kiosker_a98be1ce_ambient_light", @@ -785,18 +452,6 @@ async def test_sensors_missing_data( assert state is not None assert state.state == "unknown" - # Blackout sensor should be "inactive" when no data - blackout_state = hass.states.get("sensor.kiosker_a98be1ce_blackout_state") - assert blackout_state is not None - assert blackout_state.state == "inactive" - - # Screensaver sensor should be "hidden" when no data (this is the default) - screensaver_state = hass.states.get( - "sensor.kiosker_a98be1ce_screensaver_visibility" - ) - assert screensaver_state is not None - assert screensaver_state.state == "hidden" - async def test_sensor_unique_ids( hass: HomeAssistant, mock_config_entry: MockConfigEntry @@ -819,9 +474,9 @@ async def test_sensor_unique_ids( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = "2025-01-01T12:00:00Z" - mock_status.last_motion = "2025-01-01T11:55:00Z" - mock_status.last_update = "2025-01-01T12:05:00Z" + mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_api.status.return_value = mock_status @@ -855,45 +510,14 @@ async def test_sensor_unique_ids( entity_registry = er.async_get(hass) expected_unique_ids = [ - ("sensor.kiosker_test_sen_battery_level", "TEST_SENSOR_ID_battery_level"), - ("sensor.kiosker_test_sen_battery_state", "TEST_SENSOR_ID_battery_state"), - ("sensor.kiosker_test_sen_last_interaction", "TEST_SENSOR_ID_last_interaction"), - ("sensor.kiosker_test_sen_last_motion", "TEST_SENSOR_ID_last_motion"), - ("sensor.kiosker_test_sen_ambient_light", "TEST_SENSOR_ID_ambient_light"), - ("sensor.kiosker_test_sen_last_update", "TEST_SENSOR_ID_last_update"), - ("sensor.kiosker_test_sen_blackout_state", "TEST_SENSOR_ID_blackout_state"), - ( - "sensor.kiosker_test_sen_screensaver_visibility", - "TEST_SENSOR_ID_screensaver_visibility", - ), + ("sensor.kiosker_test_sen_battery", "TEST_SENSOR_ID_batteryLevel"), + ("sensor.kiosker_test_sen_last_interaction", "TEST_SENSOR_ID_lastInteraction"), + ("sensor.kiosker_test_sen_last_motion", "TEST_SENSOR_ID_lastMotion"), + ("sensor.kiosker_test_sen_ambient_light", "TEST_SENSOR_ID_ambientLight"), + ("sensor.kiosker_test_sen_last_update", "TEST_SENSOR_ID_lastUpdate"), ] for entity_id, expected_unique_id in expected_unique_ids: entity = entity_registry.async_get(entity_id) assert entity is not None, f"Entity {entity_id} not found" assert entity.unique_id == expected_unique_id - - -async def test_parse_datetime_function() -> None: - """Test the parse_datetime utility function.""" - - # Test with None - assert parse_datetime(None) is None - - # Test with datetime object - dt = datetime(2025, 1, 1, 12, 0, 0) - assert parse_datetime(dt) == dt - - # Test with ISO string - result = parse_datetime("2025-01-01T12:00:00Z") - assert result is not None - assert result.year == 2025 - assert result.month == 1 - assert result.day == 1 - assert result.hour == 12 - - # Test with invalid string - assert parse_datetime("invalid") is None - - # Test with non-string, non-datetime - assert parse_datetime(123) is None From f63ecac06ea8c084448c5f66e2e7e9c74e9de160 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 10:21:50 +0100 Subject: [PATCH 36/69] Remove binary sensor --- .../components/kiosker/binary_sensor.py | 115 ------------------ 1 file changed, 115 deletions(-) delete mode 100644 homeassistant/components/kiosker/binary_sensor.py diff --git a/homeassistant/components/kiosker/binary_sensor.py b/homeassistant/components/kiosker/binary_sensor.py deleted file mode 100644 index 1f2ba8340e9c4..0000000000000 --- a/homeassistant/components/kiosker/binary_sensor.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Support for Kiosker binary sensors.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import KioskerConfigEntry -from .coordinator import KioskerDataUpdateCoordinator -from .entity import KioskerEntity - -# Limit concurrent updates to prevent overwhelming the API -PARALLEL_UPDATES = 3 - - -@dataclass(frozen=True, kw_only=True) -class KioskerBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describes Kiosker binary sensor entity.""" - - value_fn: Callable[[Any], bool] - - -BINARY_SENSORS: tuple[KioskerBinarySensorEntityDescription, ...] = ( - KioskerBinarySensorEntityDescription( - key="blackoutState", - translation_key="blackout_state", - value_fn=lambda x: hasattr(x, "visible") and x.visible, - ), - KioskerBinarySensorEntityDescription( - key="screensaverVisibility", - translation_key="screensaver_visibility", - value_fn=lambda x: hasattr(x, "visible") and x.visible, - ), - KioskerBinarySensorEntityDescription( - key="charging", - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - value_fn=lambda x: ( - x.battery_state in ("Charging", "Fully Charged") - if hasattr(x, "battery_state") - else False - ), - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: KioskerConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Kiosker binary sensors based on a config entry.""" - coordinator = entry.runtime_data - - # Create all binary sensors - they will handle missing data gracefully - async_add_entities( - KioskerBinarySensor(coordinator, description) for description in BINARY_SENSORS - ) - - -class KioskerBinarySensor(KioskerEntity, BinarySensorEntity): - """Representation of a Kiosker binary sensor.""" - - entity_description: KioskerBinarySensorEntityDescription - - def __init__( - self, - coordinator: KioskerDataUpdateCoordinator, - description: KioskerBinarySensorEntityDescription, - ) -> None: - """Initialize the binary sensor entity.""" - super().__init__(coordinator, description) - - @property - def is_on(self) -> bool | None: - """Return the state of the binary sensor.""" - if not self.coordinator.data: - return None - - data_source = None - - if self.entity_description.key == "blackoutState": - data_source = self.coordinator.data.blackout - elif self.entity_description.key == "screensaverVisibility": - data_source = self.coordinator.data.screensaver - elif self.entity_description.key == "charging": - data_source = self.coordinator.data.status - - if data_source is not None: - return self.entity_description.value_fn(data_source) - return False - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return extra state attributes for blackout state sensor.""" - if not self.coordinator.data or self.entity_description.key != "blackoutState": - return None - - blackout_data = self.coordinator.data.blackout - if blackout_data is None: - return None - - return { - key: getattr(blackout_data, key) - for key in blackout_data.__dataclass_fields__ - if not key.startswith("_") - } From 8aaa161915361d90b96b9db2fc07c622047e9c5b Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 10:24:13 +0100 Subject: [PATCH 37/69] Remove binary sensor --- homeassistant/components/kiosker/__init__.py | 2 +- homeassistant/components/kiosker/icons.json | 8 - homeassistant/components/kiosker/strings.json | 8 - .../components/kiosker/test_binary_sensor.py | 459 ------------------ 4 files changed, 1 insertion(+), 476 deletions(-) delete mode 100644 tests/components/kiosker/test_binary_sensor.py diff --git a/homeassistant/components/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py index 21ba2bc5f2ef3..c95dd0d358e28 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.SENSOR] 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 effd52fe6d1c9..2936de326a2d4 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -1,13 +1,5 @@ { "entity": { - "binary_sensor": { - "blackout_state": { - "default": "mdi:monitor-off" - }, - "screensaver_visibility": { - "default": "mdi:power-sleep" - } - }, "sensor": { "ambient_light": { "default": "mdi:brightness-6" diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index dd6d9e3b702cf..606189d1d1a95 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -51,14 +51,6 @@ } }, "entity": { - "binary_sensor": { - "blackout_state": { - "name": "Blackout" - }, - "screensaver_visibility": { - "name": "Screensaver" - } - }, "sensor": { "ambient_light": { "name": "Ambient light" diff --git a/tests/components/kiosker/test_binary_sensor.py b/tests/components/kiosker/test_binary_sensor.py deleted file mode 100644 index 10cbe76cc4c08..0000000000000 --- a/tests/components/kiosker/test_binary_sensor.py +++ /dev/null @@ -1,459 +0,0 @@ -"""Test the Kiosker binary sensors.""" - -from unittest.mock import MagicMock, patch - -from kiosker import Blackout, ScreensaverState - -from homeassistant.components.kiosker.coordinator import KioskerData -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_binary_sensors_setup( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that binary sensor entities are created.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_screensaver = ScreensaverState(visible=True, disabled=False) - mock_blackout = Blackout(visible=True) - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - mock_api.blackout_get.return_value = mock_blackout - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=mock_blackout, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Check that binary sensor entities were created - expected_binary_sensors = [ - "binary_sensor.kiosker_a98be1ce_blackout", - "binary_sensor.kiosker_a98be1ce_screensaver", - "binary_sensor.kiosker_a98be1ce_charging", - ] - - for sensor_id in expected_binary_sensors: - state = hass.states.get(sensor_id) - assert state is not None, f"Binary sensor {sensor_id} was not created" - - -async def test_blackout_state_active( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test blackout state binary sensor when active.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_blackout = Blackout(visible=True, text="Test blackout") - - mock_api.status.return_value = mock_status - mock_api.blackout_get.return_value = mock_blackout - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=mock_blackout, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually trigger coordinator update to get proper state - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=mock_blackout, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check blackout state binary sensor - state = hass.states.get("binary_sensor.kiosker_a98be1ce_blackout") - assert state is not None - assert state.state == STATE_ON - - -async def test_blackout_state_inactive( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test blackout state binary sensor when inactive.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_blackout = Blackout(visible=False) - - mock_api.status.return_value = mock_status - mock_api.blackout_get.return_value = mock_blackout - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=mock_blackout, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually trigger coordinator update to get proper state - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=mock_blackout, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check blackout state binary sensor - state = hass.states.get("binary_sensor.kiosker_a98be1ce_blackout") - assert state is not None - assert state.state == STATE_OFF - - -async def test_screensaver_visible( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test screensaver visibility binary sensor when visible.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_screensaver = ScreensaverState(visible=True, disabled=False) - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually trigger coordinator update to get proper state - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check screensaver visibility binary sensor - state = hass.states.get("binary_sensor.kiosker_a98be1ce_screensaver") - assert state is not None - assert state.state == STATE_ON - - -async def test_screensaver_hidden( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test screensaver visibility binary sensor when hidden.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_screensaver = ScreensaverState(visible=False, disabled=False) - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually trigger coordinator update to get proper state - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check screensaver visibility binary sensor - state = hass.states.get("binary_sensor.kiosker_a98be1ce_screensaver") - assert state is not None - assert state.state == STATE_OFF - - -async def test_charging_binary_sensor_charging( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test charging binary sensor when battery is charging.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_state = "Charging" - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually trigger coordinator update to get proper state - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check charging binary sensor - state = hass.states.get("binary_sensor.kiosker_a98be1ce_charging") - assert state is not None - assert state.state == STATE_ON - - -async def test_charging_binary_sensor_fully_charged( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test charging binary sensor when battery is fully charged.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_state = "Fully Charged" - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually trigger coordinator update to get proper state - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check charging binary sensor - state = hass.states.get("binary_sensor.kiosker_a98be1ce_charging") - assert state is not None - assert state.state == STATE_ON - - -async def test_charging_binary_sensor_not_charging( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test charging binary sensor when battery is not charging.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_state = "not_charging" - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually trigger coordinator update to get proper state - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check charging binary sensor - state = hass.states.get("binary_sensor.kiosker_a98be1ce_charging") - assert state is not None - assert state.state == STATE_OFF From ee123185999b3d3451a46bd877c9e0c6317b4183 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 10:28:23 +0100 Subject: [PATCH 38/69] Remove lastUpdate sensor --- homeassistant/components/kiosker/icons.json | 3 - homeassistant/components/kiosker/sensor.py | 6 -- homeassistant/components/kiosker/strings.json | 3 - tests/components/kiosker/test_sensor.py | 69 ------------------- 4 files changed, 81 deletions(-) diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index 2936de326a2d4..171431fc8ac7c 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -15,9 +15,6 @@ }, "last_motion": { "default": "mdi:motion-sensor" - }, - "last_update": { - "default": "mdi:update" } } } diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index 807d7c1afd5bf..a23a42a94d793 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -59,12 +59,6 @@ class KioskerSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: x.ambient_light, ), - KioskerSensorEntityDescription( - key="lastUpdate", - translation_key="last_update", - device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda x: x.last_update, - ), ) diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 606189d1d1a95..417a30268892c 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -67,9 +67,6 @@ "unknown": "Unknown" } }, - "last_interaction": { - "name": "Last interaction" - }, "last_motion": { "name": "Last motion" }, diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index 67906d63f91eb..886cdd0f359eb 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -35,7 +35,6 @@ async def test_sensors_setup( mock_status.battery_state = "charging" mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") - mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_screensaver = MagicMock() mock_screensaver.visible = True @@ -75,7 +74,6 @@ async def test_sensors_setup( "sensor.kiosker_a98be1ce_last_interaction", "sensor.kiosker_a98be1ce_last_motion", "sensor.kiosker_a98be1ce_ambient_light", - "sensor.kiosker_a98be1ce_last_update", ] for sensor_id in expected_sensors: @@ -112,7 +110,6 @@ async def test_battery_level_sensor( mock_status.battery_state = "charging" mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") - mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_api.status.return_value = mock_status @@ -174,7 +171,6 @@ async def test_last_interaction_sensor( mock_status.battery_state = "charging" mock_status.last_interaction = datetime.fromisoformat("2026-03-03T22:41:09Z") mock_status.last_motion = datetime.fromisoformat("2025-03-03T22:40:09Z") - mock_status.last_update = datetime.fromisoformat("2026-01-03T12:41:09Z") mock_api.status.return_value = mock_status @@ -234,7 +230,6 @@ async def test_last_motion_sensor( mock_status.battery_state = "charging" mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") - mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_api.status.return_value = mock_status @@ -271,66 +266,6 @@ async def test_last_motion_sensor( assert state.attributes["device_class"] == "timestamp" -async def test_last_update_sensor( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test last update sensor.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") - mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check last update sensor - state = hass.states.get("sensor.kiosker_a98be1ce_last_update") - assert state is not None - assert state.state == "2025-01-01T12:05:00+00:00" - assert state.attributes["device_class"] == "timestamp" - - async def test_ambient_light_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -354,7 +289,6 @@ async def test_ambient_light_sensor( mock_status.battery_state = "charging" mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") - mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_status.ambient_light = 2.6 mock_api.status.return_value = mock_status @@ -444,7 +378,6 @@ async def test_sensors_missing_data( "sensor.kiosker_a98be1ce_last_interaction", "sensor.kiosker_a98be1ce_last_motion", "sensor.kiosker_a98be1ce_ambient_light", - "sensor.kiosker_a98be1ce_last_update", ] for sensor_id in sensors_with_unknown_state: @@ -476,7 +409,6 @@ async def test_sensor_unique_ids( mock_status.battery_state = "charging" mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") - mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") mock_api.status.return_value = mock_status @@ -514,7 +446,6 @@ async def test_sensor_unique_ids( ("sensor.kiosker_test_sen_last_interaction", "TEST_SENSOR_ID_lastInteraction"), ("sensor.kiosker_test_sen_last_motion", "TEST_SENSOR_ID_lastMotion"), ("sensor.kiosker_test_sen_ambient_light", "TEST_SENSOR_ID_ambientLight"), - ("sensor.kiosker_test_sen_last_update", "TEST_SENSOR_ID_lastUpdate"), ] for entity_id, expected_unique_id in expected_unique_ids: From f9652f96969eecd07e9193bd9bea0ca1511c502e Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 13:26:05 +0100 Subject: [PATCH 39/69] Changed error handling in config_flow.py --- .../components/kiosker/config_flow.py | 103 +++++++----------- .../components/kiosker/coordinator.py | 10 +- homeassistant/components/kiosker/strings.json | 1 + 3 files changed, 46 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 93a391ce94a72..03117baa5490d 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -19,7 +19,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_API_TOKEN, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN @@ -43,11 +42,13 @@ ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input( + hass: HomeAssistant, data: dict[str, Any] +) -> tuple[dict[str, str], str | None]: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - Returns title and device_id for config entry setup. + Returns a tuple of (errors dict, device_id). If validation succeeds, errors will be empty. """ api = KioskerAPI( host=data[CONF_HOST], @@ -60,29 +61,28 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: # Test connection by getting status status = await hass.async_add_executor_job(api.status) - except ConnectionError as exc: - raise CannotConnect from exc - except (AuthenticationError, IPAuthenticationError) as exc: - raise InvalidAuth from exc - except TLSVerificationError as exc: - raise TLSError from exc - except BadRequestError as exc: - raise BadRequest from exc - except PingError as exc: - raise CannotConnect from exc - except Exception as exc: + except ConnectionError: + return ({"base": "cannot_connect"}, None) + except AuthenticationError: + return ({"base": "invalid_auth"}, None) + except IPAuthenticationError: + return ({"base": "invalid_ip_auth"}, None) + except TLSVerificationError: + return ({"base": "tls_error"}, None) + except BadRequestError: + return ({"base": "bad_request"}, None) + except PingError: + return ({"base": "cannot_connect"}, None) + except Exception: _LOGGER.exception("Unexpected exception while connecting to Kiosker") - raise CannotConnect from exc + return ({"base": "unknown"}, None) # Ensure we have a device_id from the status response if not hasattr(status, "device_id") or not status.device_id: _LOGGER.error("Device did not return a valid device_id") - raise CannotConnect + return ({"base": "cannot_connect"}, None) - device_id = status.device_id - # Use first 8 characters of device_id for consistency with entity naming - display_id = device_id[:8] if len(device_id) > 8 else device_id - return {"title": f"Kiosker {display_id}", "device_id": device_id} + return ({}, status.device_id) class KioskerConfigFlow(ConfigFlow, domain=DOMAIN): @@ -106,27 +106,18 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except TLSError: - errors["base"] = "tls_error" - except BadRequest: - errors["base"] = "bad_request" - except Exception: - _LOGGER.exception("Unexpected exception during validation") - errors["base"] = "unknown" - else: + validation_errors, device_id = await validate_input(self.hass, user_input) + if validation_errors: + errors.update(validation_errors) + elif device_id: # Use device ID as unique identifier - await self.async_set_unique_id( - info["device_id"], raise_on_progress=False - ) + await self.async_set_unique_id(device_id, raise_on_progress=False) self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=user_input) + # Use first 8 characters of device_id for consistency with entity naming + display_id = device_id[:8] if len(device_id) > 8 else device_id + title = f"Kiosker {display_id}" + return self.async_create_entry(title=title, data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors @@ -197,18 +188,14 @@ async def async_step_zeroconf_confirm( CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL, DEFAULT_SSL_VERIFY), } - try: - info = await validate_input(self.hass, config_data) - except CannotConnect: - errors[CONF_API_TOKEN] = "cannot_connect" - except InvalidAuth: - errors[CONF_API_TOKEN] = "invalid_auth" - except TLSError: - errors["base"] = "tls_error" - except BadRequest: - errors["base"] = "bad_request" - else: - return self.async_create_entry(title=info["title"], data=config_data) + validation_errors, device_id = await validate_input(self.hass, config_data) + if validation_errors: + errors.update(validation_errors) + elif device_id: + # Use first 8 characters of device_id for consistency with entity naming + display_id = device_id[:8] if len(device_id) > 8 else device_id + title = f"Kiosker {display_id}" + return self.async_create_entry(title=title, data=config_data) # Show form to get API token for discovered device return self.async_show_form( @@ -217,19 +204,3 @@ async def async_step_zeroconf_confirm( description_placeholders=self.context["title_placeholders"], errors=errors, ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class TLSError(HomeAssistantError): - """Error to indicate TLS verification failed.""" - - -class BadRequest(HomeAssistantError): - """Error to indicate bad request.""" diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index cc5d33b6cf1d6..fb1f48850ec25 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -78,8 +78,14 @@ async def _async_update_data(self) -> KioskerData: status, blackout, screensaver = await self.hass.async_add_executor_job( self._fetch_all_data ) - except (AuthenticationError, IPAuthenticationError) as exc: - raise ConfigEntryAuthFailed("Authentication failed") from exc + except AuthenticationError as exc: + raise ConfigEntryAuthFailed( + "Authentication failed. Check your API token." + ) from exc + except IPAuthenticationError as exc: + raise ConfigEntryAuthFailed( + "IP authentication failed. Check your IP whitelist." + ) from exc except (ConnectionError, PingError) as exc: raise UpdateFailed(f"Connection failed: {exc}") from exc except TLSVerificationError as exc: diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 417a30268892c..f73ed7b884429 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -8,6 +8,7 @@ "bad_request": "Invalid request. Check your configuration.", "cannot_connect": "Failed to connect to the Kiosker device.", "invalid_auth": "Authentication failed. Check your API token.", + "invalid_ip_auth": "IP authentication failed. Check your IP whitelist.", "tls_error": "TLS verification failed. Check your SSL settings.", "unknown": "[%key:common::config_flow::error::unknown%]" }, From 91aacedf4e87ff808f701f55eed4b3eced3eabe2 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 13:31:58 +0100 Subject: [PATCH 40/69] Added none handling to hw_version --- homeassistant/components/kiosker/entity.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 617166213f735..670f4c64db7ae 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -58,7 +58,13 @@ def __init__( manufacturer="Top North", model=app_name, sw_version=app_version, - hw_version=f"{model} ({os_version})", + hw_version=( + None + if model is None + else model + if os_version is None + else f"{model} ({os_version})" + ), serial_number=device_id, ) From 13b2f748421972213d157a1ce7d07c0d4f17cfcf Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 13:34:24 +0100 Subject: [PATCH 41/69] Removed unused transations --- homeassistant/components/kiosker/strings.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index f73ed7b884429..cd22f43d7f4ac 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -37,12 +37,10 @@ "zeroconf_confirm": { "data": { "api_token": "API Token", - "ssl": "Use SSL", "verify_ssl": "Verify certificate" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", - "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "verify_ssl": "Verify SSL certificate. Enable for valid certificates only." }, "description": "You are about to pair `{name}` at `{host}:{port}` with Home Assistant.\n\nPlease provide the API token to complete setup.", From effcd115afec69cbc73680baec819747c9506685 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 13:41:02 +0100 Subject: [PATCH 42/69] Changed transaltions removed PARALLEL_UPDATES --- homeassistant/components/kiosker/sensor.py | 4 ++-- homeassistant/components/kiosker/strings.json | 17 +++++++---------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index a23a42a94d793..00c23832915cc 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -22,8 +22,8 @@ from .coordinator import KioskerDataUpdateCoordinator from .entity import KioskerEntity -# Limit concurrent updates to prevent overwhelming the API -PARALLEL_UPDATES = 3 +# Coordinator-based platform; no per-entity polling concurrency needed +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index cd22f43d7f4ac..609448789ab8e 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -15,11 +15,11 @@ "step": { "user": { "data": { - "api_token": "API Token", - "host": "Host", - "port": "Port", - "ssl": "Use SSL", - "verify_ssl": "Verify certificate" + "api_token": "[%key:common::config_flow::data::api_token%]", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", @@ -36,8 +36,8 @@ }, "zeroconf_confirm": { "data": { - "api_token": "API Token", - "verify_ssl": "Verify certificate" + "api_token": "[%key:common::config_flow::data::api_token%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", @@ -68,9 +68,6 @@ }, "last_motion": { "name": "Last motion" - }, - "last_update": { - "name": "Last update" } } } From a83ea235655be3d9ae026ed041ea97d2fad36ad0 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 13:49:59 +0100 Subject: [PATCH 43/69] Updated quality_scale --- .../components/kiosker/quality_scale.yaml | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index e897d4c1ad831..4e4e9e897ddc7 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -1,17 +1,23 @@ rules: # Bronze - action-setup: done + action-setup: + status: exempt + comment: Integration does not register custom actions appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: done + docs-actions: + status: exempt + comment: Integration does not provide custom actions to document docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: done + entity-event-setup: + status: exempt + comment: Integration is polling-only and does not subscribe to external events entity-unique-id: done has-entity-name: done runtime-data: done @@ -20,17 +26,19 @@ rules: unique-config-entry: done # Silver - action-exceptions: done + action-exceptions: + status: exempt + comment: Integration does not provide custom actions config-entry-unloading: done - docs-configuration-parameters: done + docs-configuration-parameters: + status: exempt + comment: Integration does not provide configuration options (no options flow) docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: - status: exempt - comment: Removed from initial PR per review feedback + reauthentication-flow: todo test-coverage: done # Gold From 4330dfd86a34ec343f425b74649dbb5df400fbe3 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 14:13:34 +0100 Subject: [PATCH 44/69] added error handling for entity ID --- homeassistant/components/kiosker/entity.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 670f4c64db7ae..f57cf3860c978 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -43,16 +43,20 @@ def __init__( os_version = None # Use uppercased truncated device ID for display purposes (device name, titles) - device_id_short_display = ( - device_id[:8].upper() if device_id != "unknown" else None - ) + if device_id is not None: + try: + device_id_short_display = device_id[:8].upper() + except TypeError, AttributeError: + device_id_short_display = "unknown" + else: + device_id_short_display = "unknown" # Set device info self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, name=( f"Kiosker {device_id_short_display}" - if device_id is not None + if device_id_short_display is not None else "Kiosker" ), manufacturer="Top North", From 8ad29786a65373c5ef08178a757d4ff3f0cdbc85 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 15:08:10 +0100 Subject: [PATCH 45/69] Review changes --- .../components/kiosker/config_flow.py | 18 +++---- homeassistant/components/kiosker/entity.py | 26 ++++----- homeassistant/components/kiosker/sensor.py | 14 ++--- homeassistant/components/kiosker/strings.json | 3 ++ tests/components/kiosker/test_config_flow.py | 53 ++++++++++--------- tests/components/kiosker/test_sensor.py | 8 +-- 6 files changed, 58 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 03117baa5490d..bc8a2b82e243d 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -78,7 +78,7 @@ async def validate_input( return ({"base": "unknown"}, None) # Ensure we have a device_id from the status response - if not hasattr(status, "device_id") or not status.device_id: + if not status.device_id: _LOGGER.error("Device did not return a valid device_id") return ({"base": "cannot_connect"}, None) @@ -96,7 +96,7 @@ def __init__(self) -> None: self._discovered_host: str | None = None self._discovered_port: int | None = None - self._discovered_uuid: str | None = None + self._discovered_device_id: str | None = None self._discovered_version: str | None = None self._discovered_ssl: bool | None = None @@ -132,15 +132,15 @@ async def async_step_zeroconf( # Extract device information from zeroconf properties properties = discovery_info.properties - uuid = properties.get("uuid") + device_id = properties.get("uuid") app_name = properties.get("app", "Kiosker") version = properties.get("version", "") ssl = properties.get("ssl", "false").lower() == "true" - # Use UUID from zeroconf - if uuid: - device_name = f"{app_name} ({uuid[:8].upper()})" - unique_id = uuid + # Use device_id from zeroconf + if device_id: + device_name = f"{app_name} ({device_id[:8].upper()})" + unique_id = device_id else: _LOGGER.debug("Zeroconf properties did not include a valid device_id") return self.async_abort(reason="cannot_connect") @@ -160,7 +160,7 @@ async def async_step_zeroconf( # Store discovered information for later use self._discovered_host = host self._discovered_port = port - self._discovered_uuid = uuid + self._discovered_device_id = device_id self._discovered_version = version self._discovered_ssl = ssl @@ -173,7 +173,7 @@ async def async_step_zeroconf_confirm( """Handle zeroconf confirmation.""" errors: dict[str, str] = {} - if user_input is not None and CONF_API_TOKEN in user_input: + if user_input is not None: # Use stored discovery info and user-provided token host = self._discovered_host port = self._discovered_port diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index f57cf3860c978..55f0559f9feb4 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -26,21 +26,12 @@ def __init__( if description: self.entity_description = description - # Use coordinator data if available, otherwise fallback to config entry data - if coordinator.data and coordinator.data.status: - status = coordinator.data.status - device_id = status.device_id - model = status.model - app_name = status.app_name - app_version = status.app_version - os_version = status.os_version - else: - # Fallback when no data is available yet - device_id = None - model = None - app_name = None - app_version = None - os_version = None + status = coordinator.data.status + device_id = status.device_id + model = status.model + app_name = status.app_name + app_version = status.app_version + os_version = status.os_version # Use uppercased truncated device ID for display purposes (device name, titles) if device_id is not None: @@ -75,3 +66,8 @@ def __init__( self._attr_unique_id = ( f"{device_id}_{description.key}" if description else f"{device_id}" ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.data is not None diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index 00c23832915cc..1b43c953c13ba 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -5,7 +5,8 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import Any + +from kiosker import Status from homeassistant.components.sensor import ( SensorDeviceClass, @@ -30,7 +31,7 @@ class KioskerSensorEntityDescription(SensorEntityDescription): """Kiosker sensor description.""" - value_fn: Callable[[Any], StateType | datetime] + value_fn: Callable[[Status], StateType | datetime] SENSORS: tuple[KioskerSensorEntityDescription, ...] = ( @@ -92,11 +93,4 @@ def __init__( @property def native_value(self) -> StateType | datetime | None: """Return the native value of the sensor.""" - if not self.coordinator.data: - return None - - status = self.coordinator.data.status - if not status: - return None - - return self.entity_description.value_fn(status) + return self.entity_description.value_fn(self.coordinator.data.status) diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 609448789ab8e..5d814980088c2 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -66,6 +66,9 @@ "unknown": "Unknown" } }, + "last_interaction": { + "name": "Last interaction" + }, "last_motion": { "name": "Last motion" } diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 4fecc441c21bf..dc3564056eaae 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -4,10 +4,9 @@ from unittest.mock import Mock, patch from kiosker import ConnectionError -import pytest from homeassistant import config_entries -from homeassistant.components.kiosker.config_flow import CannotConnect, validate_input +from homeassistant.components.kiosker.config_flow import validate_input from homeassistant.components.kiosker.const import CONF_API_TOKEN, DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant @@ -67,10 +66,10 @@ async def test_form(hass: HomeAssistant) -> None: mock_api.status.return_value = mock_status mock_api_class.return_value = mock_api - mock_validate.return_value = { - "title": "Kiosker A98BE1CE", - "device_id": "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", - } + mock_validate.return_value = ( + {}, + "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -104,7 +103,7 @@ async def test_form_invalid_host(hass: HomeAssistant) -> None: with patch( "homeassistant.components.kiosker.config_flow.validate_input", - side_effect=CannotConnect, + return_value=({"base": "cannot_connect"}, None), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -122,14 +121,14 @@ async def test_form_invalid_host(hass: HomeAssistant) -> None: async def test_form_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle unexpected exception.""" + """Test we handle unknown errors from validation.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( "homeassistant.components.kiosker.config_flow.validate_input", - side_effect=Exception, + return_value=({"base": "unknown"}, None), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -209,7 +208,10 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: "homeassistant.components.kiosker.async_setup_entry", return_value=True ) as mock_setup_entry, ): - mock_validate.return_value = {"title": "Kiosker Device"} + mock_validate.return_value = ( + {}, + "A98BE1CE-1234-1234-1234-123456789ABC", + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -221,7 +223,7 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Kiosker Device" + assert result2["title"] == "Kiosker A98BE1CE" assert result2["data"] == { CONF_HOST: "192.168.1.39", CONF_PORT: 8081, @@ -242,7 +244,7 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> with patch( "homeassistant.components.kiosker.config_flow.validate_input", - side_effect=CannotConnect, + return_value=({"base": "cannot_connect"}, None), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -252,7 +254,7 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> }, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {CONF_API_TOKEN: "cannot_connect"} + assert result2["errors"] == {"base": "cannot_connect"} async def test_abort_if_already_configured(hass: HomeAssistant) -> None: @@ -286,10 +288,10 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: mock_api.status.return_value = mock_status mock_api_class.return_value = mock_api - mock_validate.return_value = { - "title": "Kiosker A98BE1CE", - "device_id": "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", - } + mock_validate.return_value = ( + {}, + "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -330,7 +332,7 @@ async def test_zeroconf_abort_if_already_configured(hass: HomeAssistant) -> None async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None: - """Test manual setup raises CannotConnect when device_id unavailable.""" + """Test manual setup returns cannot_connect when device_id unavailable.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -338,7 +340,7 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None with ( patch( "homeassistant.components.kiosker.config_flow.validate_input", - side_effect=CannotConnect, + return_value=({"base": "cannot_connect"}, None), ), patch( "homeassistant.components.kiosker.config_flow.KioskerAPI" @@ -381,11 +383,9 @@ async def test_validate_input_success( CONF_VERIFY_SSL: False, } - result = await validate_input(hass, data) - assert result == { - "title": "Kiosker A98BE1CE", - "device_id": "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", - } + errors, device_id = await validate_input(hass, data) + assert errors == {} + assert device_id == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" async def test_validate_input_connection_error( @@ -406,5 +406,6 @@ async def test_validate_input_connection_error( CONF_VERIFY_SSL: False, } - with pytest.raises(CannotConnect): - await validate_input(hass, data) + errors, device_id = await validate_input(hass, data) + assert errors == {"base": "cannot_connect"} + assert device_id is None diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index 886cdd0f359eb..c07709bbd6446 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -372,18 +372,18 @@ async def test_sensors_missing_data( coordinator.async_update_listeners() await hass.async_block_till_done() - # Check that sensors handle missing data gracefully - sensors_with_unknown_state = [ + # Check that sensors are unavailable when coordinator data is missing + sensors_with_unavailable_state = [ "sensor.kiosker_a98be1ce_battery", "sensor.kiosker_a98be1ce_last_interaction", "sensor.kiosker_a98be1ce_last_motion", "sensor.kiosker_a98be1ce_ambient_light", ] - for sensor_id in sensors_with_unknown_state: + for sensor_id in sensors_with_unavailable_state: state = hass.states.get(sensor_id) assert state is not None - assert state.state == "unknown" + assert state.state == "unavailable" async def test_sensor_unique_ids( From cbe3c472723f02b979d9852dbc323f8b1e56d594 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 16:37:05 +0100 Subject: [PATCH 46/69] Copilot Review changes --- homeassistant/components/kiosker/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index fb1f48850ec25..b9fc821bd742c 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -95,7 +95,7 @@ async def _async_update_data(self) -> KioskerData: except (OSError, TimeoutError) as exc: raise UpdateFailed(f"Connection timeout: {exc}") from exc except Exception as exc: - _LOGGER.debug("Unexpected error updating Kiosker data") + _LOGGER.exception("Unexpected error updating Kiosker data") raise UpdateFailed(f"Unexpected error: {exc}") from exc return KioskerData( From 13abab905691f56e40362bdcafeea58cdf222df7 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 16:41:19 +0100 Subject: [PATCH 47/69] Copilot Review changes --- tests/components/kiosker/conftest.py | 6 ++--- tests/components/kiosker/test_sensor.py | 36 ++++++++++++++++--------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/tests/components/kiosker/conftest.py b/tests/components/kiosker/conftest.py index 335a78c32c131..ab8afd75aa0d9 100644 --- a/tests/components/kiosker/conftest.py +++ b/tests/components/kiosker/conftest.py @@ -56,9 +56,9 @@ def mock_kiosker_api(): mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") - mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00Z") + mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00+00:00") + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00+00:00") mock_api.status.return_value = mock_status diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index c07709bbd6446..ce90f08c9e312 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -33,8 +33,10 @@ async def test_sensors_setup( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_interaction = datetime.fromisoformat( + "2025-01-01T12:00:00+00:00" + ) + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") mock_screensaver = MagicMock() mock_screensaver.visible = True @@ -108,8 +110,10 @@ async def test_battery_level_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 42 mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_interaction = datetime.fromisoformat( + "2025-01-01T12:00:00+00:00" + ) + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") mock_api.status.return_value = mock_status @@ -169,8 +173,10 @@ async def test_last_interaction_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2026-03-03T22:41:09Z") - mock_status.last_motion = datetime.fromisoformat("2025-03-03T22:40:09Z") + mock_status.last_interaction = datetime.fromisoformat( + "2026-03-03T22:41:09+00:00" + ) + mock_status.last_motion = datetime.fromisoformat("2025-03-03T22:40:09+00:00") mock_api.status.return_value = mock_status @@ -228,8 +234,10 @@ async def test_last_motion_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_interaction = datetime.fromisoformat( + "2025-01-01T12:00:00+00:00" + ) + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") mock_api.status.return_value = mock_status @@ -287,8 +295,10 @@ async def test_ambient_light_sensor( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_interaction = datetime.fromisoformat( + "2025-01-01T12:00:00+00:00" + ) + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") mock_status.ambient_light = 2.6 mock_api.status.return_value = mock_status @@ -407,8 +417,10 @@ async def test_sensor_unique_ids( mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00Z") - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00Z") + mock_status.last_interaction = datetime.fromisoformat( + "2025-01-01T12:00:00+00:00" + ) + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") mock_api.status.return_value = mock_status From 2ac64addbf83fe61e777ffc3b8dfb35e16c30f76 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Wed, 4 Mar 2026 16:53:30 +0100 Subject: [PATCH 48/69] Copilot Review changes --- homeassistant/components/kiosker/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index 1b43c953c13ba..989b8de6e661c 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -31,7 +31,7 @@ class KioskerSensorEntityDescription(SensorEntityDescription): """Kiosker sensor description.""" - value_fn: Callable[[Status], StateType | datetime] + value_fn: Callable[[Status], StateType | datetime | None] SENSORS: tuple[KioskerSensorEntityDescription, ...] = ( From 0279c90468f803feecffdecd331a766ef747ea95 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 5 Mar 2026 23:30:20 +0100 Subject: [PATCH 49/69] Removed PORT in config --- homeassistant/components/kiosker/config_flow.py | 15 ++++----------- homeassistant/components/kiosker/const.py | 2 +- homeassistant/components/kiosker/coordinator.py | 6 +++--- homeassistant/components/kiosker/strings.json | 6 ++---- tests/components/kiosker/test_config_flow.py | 14 +------------- 5 files changed, 11 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index bc8a2b82e243d..e00f38bcbcbcf 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -17,18 +17,17 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import CONF_API_TOKEN, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN +from .const import CONF_API_TOKEN, DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN, PORT _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_API_TOKEN): str, vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, @@ -52,7 +51,7 @@ async def validate_input( """ api = KioskerAPI( host=data[CONF_HOST], - port=data[CONF_PORT], + port=PORT, token=data[CONF_API_TOKEN], ssl=data[CONF_SSL], verify=data[CONF_VERIFY_SSL], @@ -95,7 +94,6 @@ def __init__(self) -> None: """Initialize the config flow.""" self._discovered_host: str | None = None - self._discovered_port: int | None = None self._discovered_device_id: str | None = None self._discovered_version: str | None = None self._discovered_ssl: bool | None = None @@ -128,7 +126,6 @@ async def async_step_zeroconf( ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host - port = discovery_info.port or DEFAULT_PORT # Extract device information from zeroconf properties properties = discovery_info.properties @@ -153,13 +150,11 @@ async def async_step_zeroconf( self.context["title_placeholders"] = { "name": device_name, "host": host, - "port": str(port), "ssl": ssl, } # Store discovered information for later use self._discovered_host = host - self._discovered_port = port self._discovered_device_id = device_id self._discovered_version = version self._discovered_ssl = ssl @@ -176,13 +171,11 @@ async def async_step_zeroconf_confirm( if user_input is not None: # Use stored discovery info and user-provided token host = self._discovered_host - port = self._discovered_port ssl = self._discovered_ssl - # Create config with discovered host/port and user-provided token + # Create config with discovered host and user-provided token config_data = { CONF_HOST: host, - CONF_PORT: port, CONF_API_TOKEN: user_input[CONF_API_TOKEN], CONF_SSL: ssl, CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL, DEFAULT_SSL_VERIFY), diff --git a/homeassistant/components/kiosker/const.py b/homeassistant/components/kiosker/const.py index c54d0a99bbcca..40cc8b9d03310 100644 --- a/homeassistant/components/kiosker/const.py +++ b/homeassistant/components/kiosker/const.py @@ -6,7 +6,7 @@ CONF_API_TOKEN = "api_token" # Default values -DEFAULT_PORT = 8081 +PORT = 8081 POLL_INTERVAL = 15 DEFAULT_SSL = False DEFAULT_SSL_VERIFY = False diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py index b9fc821bd742c..49713cff45ad3 100644 --- a/homeassistant/components/kiosker/coordinator.py +++ b/homeassistant/components/kiosker/coordinator.py @@ -20,12 +20,12 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_API_TOKEN, DOMAIN, POLL_INTERVAL +from .const import CONF_API_TOKEN, DOMAIN, POLL_INTERVAL, PORT _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def __init__( """Initialize.""" self.api = KioskerAPI( host=config_entry.data[CONF_HOST], - port=config_entry.data[CONF_PORT], + port=PORT, token=config_entry.data[CONF_API_TOKEN], ssl=config_entry.data.get(CONF_SSL, False), verify=config_entry.data.get(CONF_VERIFY_SSL, False), diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 5d814980088c2..3f104de86da2a 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -17,14 +17,12 @@ "data": { "api_token": "[%key:common::config_flow::data::api_token%]", "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", "host": "The hostname or IP address of the device running the Kiosker App", - "port": "The port on which the Kiosker App is running. Default is 8081.", "ssl": "Connect to the Kiosker App using HTTPS. The Kiosker API has to be configured for SSL.", "verify_ssl": "Verify SSL certificate. Enable for valid certificates only." }, @@ -32,7 +30,7 @@ "title": "Pair Kiosker App" }, "zeroconf": { - "description": "Do you want to configure {name} at {host}:{port}?" + "description": "Do you want to configure {name} at {host}?" }, "zeroconf_confirm": { "data": { @@ -43,7 +41,7 @@ "api_token": "The API token for the Kiosker App. This can be generated in the app API settings.", "verify_ssl": "Verify SSL certificate. Enable for valid certificates only." }, - "description": "You are about to pair `{name}` at `{host}:{port}` with Home Assistant.\n\nPlease provide the API token to complete setup.", + "description": "You are about to pair `{name}` at `{host}` with Home Assistant.\n\nPlease provide the API token to complete setup.", "submit": "Pair", "title": "Discovered Kiosker App" } diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index dc3564056eaae..ee86e9ebeb1b5 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant import config_entries from homeassistant.components.kiosker.config_flow import validate_input from homeassistant.components.kiosker.const import CONF_API_TOKEN, DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -75,7 +75,6 @@ async def test_form(hass: HomeAssistant) -> None: result["flow_id"], { CONF_HOST: "192.168.1.100", - CONF_PORT: 8081, CONF_API_TOKEN: "test-token", CONF_SSL: False, CONF_VERIFY_SSL: False, @@ -87,7 +86,6 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["title"] == "Kiosker A98BE1CE" assert result2["data"] == { CONF_HOST: "192.168.1.100", - CONF_PORT: 8081, CONF_API_TOKEN: "test-token", CONF_SSL: False, CONF_VERIFY_SSL: False, @@ -109,7 +107,6 @@ async def test_form_invalid_host(hass: HomeAssistant) -> None: result["flow_id"], { CONF_HOST: "192.168.1.100", - CONF_PORT: 8081, CONF_API_TOKEN: "test-token", CONF_SSL: False, CONF_VERIFY_SSL: False, @@ -134,7 +131,6 @@ async def test_form_unexpected_exception(hass: HomeAssistant) -> None: result["flow_id"], { CONF_HOST: "192.168.1.100", - CONF_PORT: 8081, CONF_API_TOKEN: "test-token", CONF_SSL: False, CONF_VERIFY_SSL: False, @@ -158,7 +154,6 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["description_placeholders"] == { "name": "Kiosker (A98BE1CE)", "host": "192.168.1.39", - "port": "8081", "ssl": True, } @@ -226,7 +221,6 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: assert result2["title"] == "Kiosker A98BE1CE" assert result2["data"] == { CONF_HOST: "192.168.1.39", - CONF_PORT: 8081, CONF_API_TOKEN: "test-token", CONF_SSL: True, CONF_VERIFY_SSL: False, @@ -263,7 +257,6 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", - CONF_PORT: 8081, CONF_API_TOKEN: "test_token", }, unique_id="A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", @@ -297,7 +290,6 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: result["flow_id"], { CONF_HOST: "192.168.1.200", - CONF_PORT: 8081, CONF_API_TOKEN: "test-token", CONF_SSL: False, CONF_VERIFY_SSL: False, @@ -314,7 +306,6 @@ async def test_zeroconf_abort_if_already_configured(hass: HomeAssistant) -> None domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", - CONF_PORT: 8081, CONF_API_TOKEN: "test_token", }, unique_id="A98BE1CE-1234-1234-1234-123456789ABC", @@ -355,7 +346,6 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None result["flow_id"], { CONF_HOST: "192.168.1.100", - CONF_PORT: 8081, CONF_API_TOKEN: "test-token", CONF_SSL: False, CONF_VERIFY_SSL: False, @@ -377,7 +367,6 @@ async def test_validate_input_success( data = { CONF_HOST: "10.0.1.5", - CONF_PORT: 8081, CONF_API_TOKEN: "test_token", CONF_SSL: False, CONF_VERIFY_SSL: False, @@ -400,7 +389,6 @@ async def test_validate_input_connection_error( data = { CONF_HOST: "192.168.1.100", - CONF_PORT: 8081, CONF_API_TOKEN: "test_token", CONF_SSL: False, CONF_VERIFY_SSL: False, From 3b34b092e08d1e935acbfbf974e922d23068b537 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Fri, 6 Mar 2026 00:22:42 +0100 Subject: [PATCH 50/69] Syntax error and redundant null-check --- homeassistant/components/kiosker/entity.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 55f0559f9feb4..95205fa292d5b 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -45,11 +45,7 @@ def __init__( # Set device info self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, - name=( - f"Kiosker {device_id_short_display}" - if device_id_short_display is not None - else "Kiosker" - ), + name=(f"Kiosker {device_id_short_display}"), manufacturer="Top North", model=app_name, sw_version=app_version, From 8b912a5900437dff2ed15e72cfba7799f9c87d19 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 26 Mar 2026 23:41:55 +0100 Subject: [PATCH 51/69] Review Changes --- homeassistant/components/kiosker/entity.py | 16 ++++++---------- homeassistant/components/kiosker/strings.json | 9 --------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 95205fa292d5b..39a0f0f0c8387 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -18,13 +18,12 @@ class KioskerEntity(CoordinatorEntity[KioskerDataUpdateCoordinator]): def __init__( self, coordinator: KioskerDataUpdateCoordinator, - description: EntityDescription | None = None, + description: EntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - if description: - self.entity_description = description + self.entity_description = description status = coordinator.data.status device_id = status.device_id @@ -34,12 +33,9 @@ def __init__( os_version = status.os_version # Use uppercased truncated device ID for display purposes (device name, titles) - if device_id is not None: - try: - device_id_short_display = device_id[:8].upper() - except TypeError, AttributeError: - device_id_short_display = "unknown" - else: + try: + device_id_short_display = device_id[:8].upper() + except TypeError, AttributeError: device_id_short_display = "unknown" # Set device info @@ -66,4 +62,4 @@ def __init__( @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self.coordinator.data is not None + return super().available diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 3f104de86da2a..5402465ccf7e6 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -55,15 +55,6 @@ "battery_level": { "name": "Battery level" }, - "battery_state": { - "name": "Battery state", - "state": { - "charging": "Charging", - "fully_charged": "Fully Charged", - "not_charging": "Not Charging", - "unknown": "Unknown" - } - }, "last_interaction": { "name": "Last interaction" }, From fae397a55bdd30f38c14986e3f05bec6e2077959 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Thu, 2 Apr 2026 22:37:22 +0200 Subject: [PATCH 52/69] Test review --- .../components/kiosker/config_flow.py | 1 - tests/components/kiosker/conftest.py | 61 +++--- tests/components/kiosker/test_config_flow.py | 91 +++------ tests/components/kiosker/test_init.py | 184 +++++------------- tests/components/kiosker/test_sensor.py | 58 ------ 5 files changed, 109 insertions(+), 286 deletions(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index e00f38bcbcbcf..3dc7face13dd2 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -150,7 +150,6 @@ async def async_step_zeroconf( self.context["title_placeholders"] = { "name": device_name, "host": host, - "ssl": ssl, } # Store discovered information for later use diff --git a/tests/components/kiosker/conftest.py b/tests/components/kiosker/conftest.py index ab8afd75aa0d9..d9710a59fee4d 100644 --- a/tests/components/kiosker/conftest.py +++ b/tests/components/kiosker/conftest.py @@ -41,34 +41,35 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_kiosker_api(): - """Mock KioskerAPI.""" - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api.port = 8081 - - # Mock status data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat("2025-01-01T12:00:00+00:00") - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") - mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00+00:00") - - mock_api.status.return_value = mock_status - - return mock_api - - -@pytest.fixture -def mock_kiosker_api_class(): +def mock_kiosker_api() -> Generator[MagicMock]: """Mock the KioskerAPI class.""" - with patch( - "homeassistant.components.kiosker.config_flow.KioskerAPI" - ) as mock_api_class: - yield mock_api_class + with ( + patch( + "homeassistant.components.kiosker.config_flow.KioskerAPI" + ) as mock_api_class, + patch( + "homeassistant.components.kiosker.coordinator.KioskerAPI", + new=mock_api_class, + ), + ): + mock_api = mock_api_class.return_value + mock_api.host = "10.0.1.5" + mock_api.port = 8081 + + mock_status = MagicMock() + mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + mock_status.model = "iPad Pro" + mock_status.os_version = "18.0" + mock_status.app_name = "Kiosker" + mock_status.app_version = "25.1.1" + mock_status.battery_level = 85 + mock_status.battery_state = "charging" + mock_status.last_interaction = datetime.fromisoformat( + "2025-01-01T12:00:00+00:00" + ) + mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") + mock_status.last_update = datetime.fromisoformat("2025-01-01T12:05:00+00:00") + + mock_api.status.return_value = mock_status + + yield mock_api diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index ee86e9ebeb1b5..9861371fea879 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Kiosker config flow.""" from ipaddress import ip_address -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from kiosker import ConnectionError @@ -22,7 +22,7 @@ name="Kiosker Device._kiosker._tcp.local.", port=8081, properties={ - "uuid": "A98BE1CE-1234-1234-1234-123456789ABC", + "uuid": "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", "app": "Kiosker", "version": "1.0.0", "ssl": "true", @@ -41,46 +41,27 @@ ) -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_user_flow_creates_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_kiosker_api: MagicMock, +) -> None: + """Test the full user config flow creates a config entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.kiosker.config_flow.validate_input" - ) as mock_validate, - patch( - "homeassistant.components.kiosker.async_setup_entry", return_value=True - ) as mock_setup_entry, - patch( - "homeassistant.components.kiosker.config_flow.KioskerAPI" - ) as mock_api_class, - ): - mock_status = Mock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_api = Mock() - mock_api.status.return_value = mock_status - mock_api_class.return_value = mock_api - - mock_validate.return_value = ( - {}, - "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "192.168.1.100", - CONF_API_TOKEN: "test-token", - CONF_SSL: False, - CONF_VERIFY_SSL: False, - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Kiosker A98BE1CE" @@ -90,6 +71,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_SSL: False, CONF_VERIFY_SSL: False, } + assert result2["result"].unique_id == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" assert len(mock_setup_entry.mock_calls) == 1 @@ -154,7 +136,6 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["description_placeholders"] == { "name": "Kiosker (A98BE1CE)", "host": "192.168.1.39", - "ssl": True, } @@ -205,7 +186,7 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: ): mock_validate.return_value = ( {}, - "A98BE1CE-1234-1234-1234-123456789ABC", + "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", ) result2 = await hass.config_entries.flow.async_configure( @@ -215,7 +196,6 @@ async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: CONF_VERIFY_SSL: False, }, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Kiosker A98BE1CE" @@ -251,17 +231,11 @@ async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> assert result2["errors"] == {"base": "cannot_connect"} -async def test_abort_if_already_configured(hass: HomeAssistant) -> None: +async def test_abort_if_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test we abort if already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "192.168.1.100", - CONF_API_TOKEN: "test_token", - }, - unique_id="A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -300,17 +274,11 @@ async def test_abort_if_already_configured(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" -async def test_zeroconf_abort_if_already_configured(hass: HomeAssistant) -> None: +async def test_zeroconf_abort_if_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test we abort zeroconf discovery if already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "192.168.1.100", - CONF_API_TOKEN: "test_token", - }, - unique_id="A98BE1CE-1234-1234-1234-123456789ABC", - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -359,12 +327,9 @@ async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None async def test_validate_input_success( hass: HomeAssistant, mock_kiosker_api: Mock, - mock_kiosker_api_class: Mock, ) -> None: """Test validate_input with successful connection.""" - mock_kiosker_api_class.return_value = mock_kiosker_api - data = { CONF_HOST: "10.0.1.5", CONF_API_TOKEN: "test_token", @@ -380,12 +345,10 @@ async def test_validate_input_success( async def test_validate_input_connection_error( hass: HomeAssistant, mock_kiosker_api: Mock, - mock_kiosker_api_class: Mock, ) -> None: """Test validate_input with connection error.""" mock_kiosker_api.status.side_effect = ConnectionError("Connection failed") - mock_kiosker_api_class.return_value = mock_kiosker_api data = { CONF_HOST: "192.168.1.100", diff --git a/tests/components/kiosker/test_init.py b/tests/components/kiosker/test_init.py index 0af142837c762..3897bd1e2d3f1 100644 --- a/tests/components/kiosker/test_init.py +++ b/tests/components/kiosker/test_init.py @@ -1,9 +1,8 @@ """Test the Kiosker integration initialization.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from homeassistant.components.kiosker.const import DOMAIN -from homeassistant.components.kiosker.coordinator import KioskerData from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -14,157 +13,76 @@ async def test_async_setup_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_kiosker_api: MagicMock, ) -> None: """Test a successful setup entry and unload.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.LOADED - - # Test unload - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED async def test_async_setup_entry_failure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_kiosker_api: MagicMock, ) -> None: """Test an unsuccessful setup entry.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API that fails - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data", - side_effect=Exception("Connection failed"), - ): - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_kiosker_api.status.side_effect = Exception("Connection failed") + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_device_info( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_kiosker_api: MagicMock, device_registry: dr.DeviceRegistry, ) -> None: """Test device registry integration.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - await setup_integration(hass, mock_config_entry) - - # Check device was registered correctly - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC")} - ) - assert device_entry is not None - assert device_entry.name == "Kiosker A98BE1CE" - assert device_entry.manufacturer == "Top North" - assert device_entry.model == "Kiosker" - assert device_entry.sw_version == "25.1.1" - assert device_entry.hw_version == "iPad Pro (18.0)" - assert device_entry.serial_number == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC")} + ) + assert device_entry is not None + assert device_entry.name == "Kiosker A98BE1CE" + assert device_entry.manufacturer == "Top North" + assert device_entry.model == "Kiosker" + assert device_entry.sw_version == "25.1.1" + assert device_entry.hw_version == "iPad Pro (18.0)" + assert device_entry.serial_number == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" async def test_device_identifiers_and_info( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_kiosker_api: MagicMock, device_registry: dr.DeviceRegistry, ) -> None: """Test device identifiers and device info are set correctly.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data with specific device info - mock_status = MagicMock() - mock_status.device_id = "TEST_DEVICE_123" - mock_status.model = "iPad Mini" - mock_status.os_version = "17.5" - mock_status.app_name = "Kiosker" - mock_status.app_version = "24.1.0" - - mock_api.status.return_value = mock_status - - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - await setup_integration(hass, mock_config_entry) - - # Check device was registered with correct info - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, "TEST_DEVICE_123")} - ) - assert device_entry is not None - assert device_entry.name == "Kiosker TEST_DEV" - assert device_entry.manufacturer == "Top North" - assert device_entry.model == "Kiosker" - assert device_entry.sw_version == "24.1.0" - assert device_entry.hw_version == "iPad Mini (17.5)" - assert device_entry.serial_number == "TEST_DEVICE_123" - assert device_entry.identifiers == {(DOMAIN, "TEST_DEVICE_123")} + mock_status = mock_kiosker_api.status.return_value + mock_status.device_id = "TEST_DEVICE_123" + mock_status.model = "iPad Mini" + mock_status.os_version = "17.5" + mock_status.app_name = "Kiosker" + mock_status.app_version = "24.1.0" + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "TEST_DEVICE_123")} + ) + assert device_entry is not None + assert device_entry.name == "Kiosker TEST_DEV" + assert device_entry.manufacturer == "Top North" + assert device_entry.model == "Kiosker" + assert device_entry.sw_version == "24.1.0" + assert device_entry.hw_version == "iPad Mini (17.5)" + assert device_entry.serial_number == "TEST_DEVICE_123" + assert device_entry.identifiers == {(DOMAIN, "TEST_DEVICE_123")} diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index ce90f08c9e312..971a81e3b57b4 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -338,64 +338,6 @@ async def test_ambient_light_sensor( assert "unit_of_measurement" not in state.attributes -async def test_sensors_missing_data( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test sensors when data is missing.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data with missing attributes - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration with missing data (None coordinator data) - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Test missing data by setting coordinator data to None - coordinator = mock_config_entry.runtime_data - coordinator.data = None - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check that sensors are unavailable when coordinator data is missing - sensors_with_unavailable_state = [ - "sensor.kiosker_a98be1ce_battery", - "sensor.kiosker_a98be1ce_last_interaction", - "sensor.kiosker_a98be1ce_last_motion", - "sensor.kiosker_a98be1ce_ambient_light", - ] - - for sensor_id in sensors_with_unavailable_state: - state = hass.states.get(sensor_id) - assert state is not None - assert state.state == "unavailable" - - async def test_sensor_unique_ids( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: From 6bb8727024a5a3169a390ee47e0d82e16c455d31 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Tue, 7 Apr 2026 22:59:04 +0200 Subject: [PATCH 53/69] Test review --- tests/components/kiosker/conftest.py | 4 +- .../kiosker/snapshots/test_init.ambr | 32 ++ .../kiosker/snapshots/test_sensor.ambr | 207 +++++++++ tests/components/kiosker/test_config_flow.py | 330 +++++--------- tests/components/kiosker/test_init.py | 39 +- tests/components/kiosker/test_sensor.py | 410 +----------------- 6 files changed, 378 insertions(+), 644 deletions(-) create mode 100644 tests/components/kiosker/snapshots/test_init.ambr create mode 100644 tests/components/kiosker/snapshots/test_sensor.ambr diff --git a/tests/components/kiosker/conftest.py b/tests/components/kiosker/conftest.py index d9710a59fee4d..b73bac1096fd7 100644 --- a/tests/components/kiosker/conftest.py +++ b/tests/components/kiosker/conftest.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.kiosker.const import CONF_API_TOKEN, DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL from tests.common import MockConfigEntry @@ -31,7 +31,6 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, data={ CONF_HOST: "10.0.1.5", - CONF_PORT: 8081, CONF_API_TOKEN: "test_token", CONF_SSL: False, CONF_VERIFY_SSL: False, @@ -64,6 +63,7 @@ def mock_kiosker_api() -> Generator[MagicMock]: mock_status.app_version = "25.1.1" mock_status.battery_level = 85 mock_status.battery_state = "charging" + mock_status.ambient_light = 2.6 mock_status.last_interaction = datetime.fromisoformat( "2025-01-01T12:00:00+00:00" ) diff --git a/tests/components/kiosker/snapshots/test_init.ambr b/tests/components/kiosker/snapshots/test_init.ambr new file mode 100644 index 0000000000000..f179210737120 --- /dev/null +++ b/tests/components/kiosker/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'iPad Pro (18.0)', + 'id': , + 'identifiers': set({ + tuple( + 'kiosker', + 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Top North', + 'model': 'Kiosker', + 'model_id': None, + 'name': 'Kiosker A98BE1CE', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC', + 'sw_version': '25.1.1', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/kiosker/snapshots/test_sensor.ambr b/tests/components/kiosker/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..f6893ee18412d --- /dev/null +++ b/tests/components/kiosker/snapshots/test_sensor.ambr @@ -0,0 +1,207 @@ +# serializer version: 1 +# name: test_all_entities[sensor.kiosker_a98be1ce_ambient_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kiosker_a98be1ce_ambient_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ambient light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ambient light', + 'platform': 'kiosker', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ambient_light', + 'unique_id': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC_ambientLight', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kiosker_a98be1ce_ambient_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kiosker A98BE1CE Ambient light', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.kiosker_a98be1ce_ambient_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.6', + }) +# --- +# name: test_all_entities[sensor.kiosker_a98be1ce_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kiosker_a98be1ce_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'kiosker', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC_batteryLevel', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.kiosker_a98be1ce_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kiosker A98BE1CE Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kiosker_a98be1ce_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85', + }) +# --- +# name: test_all_entities[sensor.kiosker_a98be1ce_last_interaction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kiosker_a98be1ce_last_interaction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last interaction', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last interaction', + 'platform': 'kiosker', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_interaction', + 'unique_id': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC_lastInteraction', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kiosker_a98be1ce_last_interaction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Kiosker A98BE1CE Last interaction', + }), + 'context': , + 'entity_id': 'sensor.kiosker_a98be1ce_last_interaction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T12:00:00+00:00', + }) +# --- +# name: test_all_entities[sensor.kiosker_a98be1ce_last_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kiosker_a98be1ce_last_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last motion', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last motion', + 'platform': 'kiosker', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC_lastMotion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kiosker_a98be1ce_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Kiosker A98BE1CE Last motion', + }), + 'context': , + 'entity_id': 'sensor.kiosker_a98be1ce_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T11:55:00+00:00', + }) +# --- diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 9861371fea879..96a467f518f21 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -1,9 +1,17 @@ """Test the Kiosker config flow.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from kiosker import ConnectionError +from unittest.mock import AsyncMock, MagicMock + +from kiosker import ( + AuthenticationError, + BadRequestError, + ConnectionError, + IPAuthenticationError, + PingError, + TLSVerificationError, +) +import pytest from homeassistant import config_entries from homeassistant.components.kiosker.config_flow import validate_input @@ -75,56 +83,62 @@ async def test_user_flow_creates_entry( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_host(hass: HomeAssistant) -> None: - """Test we handle invalid host.""" +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ConnectionError(), "cannot_connect"), + (AuthenticationError(), "invalid_auth"), + (IPAuthenticationError(), "invalid_ip_auth"), + (TLSVerificationError(), "tls_error"), + (BadRequestError(), "bad_request"), + (PingError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_user_flow_errors_and_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_kiosker_api: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test user flow handles all validation errors and can recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.kiosker.config_flow.validate_input", - return_value=({"base": "cannot_connect"}, None), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "192.168.1.100", - CONF_API_TOKEN: "test-token", - CONF_SSL: False, - CONF_VERIFY_SSL: False, - }, - ) - + mock_kiosker_api.status.side_effect = exception + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, + ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - + assert result2["errors"] == {"base": error} -async def test_form_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle unknown errors from validation.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + # Test that the flow recovers on retry + mock_kiosker_api.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, ) - - with patch( - "homeassistant.components.kiosker.config_flow.validate_input", - return_value=({"base": "unknown"}, None), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "192.168.1.100", - CONF_API_TOKEN: "test-token", - CONF_SSL: False, - CONF_VERIFY_SSL: False, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result3["type"] is FlowResultType.CREATE_ENTRY -async def test_zeroconf(hass: HomeAssistant) -> None: - """Test zeroconf discovery.""" +async def test_zeroconf( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, +) -> None: + """Test the full zeroconf discovery flow creates a config entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -132,107 +146,60 @@ async def test_zeroconf(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "zeroconf_confirm" - # Check description placeholders instead of context assert result["description_placeholders"] == { "name": "Kiosker (A98BE1CE)", "host": "192.168.1.39", } - - -async def test_zeroconf_no_uuid(hass: HomeAssistant) -> None: - """Test zeroconf discovery without UUID aborts with cannot_connect.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=DISCOVERY_INFO_NO_UUID, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_zeroconf_confirm(hass: HomeAssistant) -> None: - """Test zeroconf confirmation step shows form for API token.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=DISCOVERY_INFO, - ) - - result_confirm = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=None - ) - assert result_confirm["type"] is FlowResultType.FORM - assert result_confirm["step_id"] == "zeroconf_confirm" - # Check that the form includes API token field - schema_keys = list(result_confirm["data_schema"].schema.keys()) + schema_keys = list(result["data_schema"].schema.keys()) assert any(key.schema == CONF_API_TOKEN for key in schema_keys) - -async def test_zeroconf_discovery_confirm(hass: HomeAssistant) -> None: - """Test zeroconf discovery confirmation with token.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=DISCOVERY_INFO, + # Test error handling + mock_kiosker_api.status.side_effect = ConnectionError() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: "test-token", + CONF_VERIFY_SSL: False, + }, ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} - with ( - patch( - "homeassistant.components.kiosker.config_flow.validate_input" - ) as mock_validate, - patch( - "homeassistant.components.kiosker.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - mock_validate.return_value = ( - {}, - "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_API_TOKEN: "test-token", - CONF_VERIFY_SSL: False, - }, - ) - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Kiosker A98BE1CE" - assert result2["data"] == { + # Test recovery + mock_kiosker_api.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: "test-token", + CONF_VERIFY_SSL: False, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Kiosker A98BE1CE" + assert result3["data"] == { CONF_HOST: "192.168.1.39", CONF_API_TOKEN: "test-token", CONF_SSL: True, CONF_VERIFY_SSL: False, } - assert len(mock_setup_entry.mock_calls) == 1 + assert result3["result"].unique_id == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" -async def test_zeroconf_discovery_confirm_cannot_connect(hass: HomeAssistant) -> None: - """Test zeroconf discovery confirmation with connection error.""" +async def test_zeroconf_no_uuid(hass: HomeAssistant) -> None: + """Test zeroconf discovery without UUID aborts with cannot_connect.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=DISCOVERY_INFO, + data=DISCOVERY_INFO_NO_UUID, ) - - with patch( - "homeassistant.components.kiosker.config_flow.validate_input", - return_value=({"base": "cannot_connect"}, None), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_API_TOKEN: "test-token", - CONF_VERIFY_SSL: False, - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" async def test_abort_if_already_configured( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_kiosker_api: MagicMock, ) -> None: """Test we abort if already configured.""" mock_config_entry.add_to_hass(hass) @@ -241,37 +208,31 @@ async def test_abort_if_already_configured( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.kiosker.config_flow.validate_input" - ) as mock_validate, - patch( - "homeassistant.components.kiosker.config_flow.KioskerAPI" - ) as mock_api_class, - ): - mock_status = Mock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_api = Mock() - mock_api.status.return_value = mock_status - mock_api_class.return_value = mock_api - - mock_validate.return_value = ( - {}, - "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC", - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "192.168.1.200", - CONF_API_TOKEN: "test-token", - CONF_SSL: False, - CONF_VERIFY_SSL: False, - }, - ) - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + mock_kiosker_api.status.side_effect = ConnectionError() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.200", + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + mock_kiosker_api.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.200", + CONF_API_TOKEN: "test-token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, + ) + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "already_configured" async def test_zeroconf_abort_if_already_configured( @@ -290,45 +251,12 @@ async def test_zeroconf_abort_if_already_configured( assert result["reason"] == "already_configured" -async def test_manual_setup_with_device_id_fallback(hass: HomeAssistant) -> None: - """Test manual setup returns cannot_connect when device_id unavailable.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with ( - patch( - "homeassistant.components.kiosker.config_flow.validate_input", - return_value=({"base": "cannot_connect"}, None), - ), - patch( - "homeassistant.components.kiosker.config_flow.KioskerAPI" - ) as mock_api_class, - ): - # Mock API that fails to get status - mock_api = Mock() - mock_api.status.side_effect = Exception("Connection failed") - mock_api_class.return_value = mock_api - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "192.168.1.100", - CONF_API_TOKEN: "test-token", - CONF_SSL: False, - CONF_VERIFY_SSL: False, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_validate_input_success( +async def test_validate_input_no_device_id( hass: HomeAssistant, - mock_kiosker_api: Mock, + mock_kiosker_api: MagicMock, ) -> None: - """Test validate_input with successful connection.""" + """Test validate_input returns cannot_connect when device has no device_id.""" + mock_kiosker_api.status.return_value.device_id = None data = { CONF_HOST: "10.0.1.5", @@ -337,26 +265,6 @@ async def test_validate_input_success( CONF_VERIFY_SSL: False, } - errors, device_id = await validate_input(hass, data) - assert errors == {} - assert device_id == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - - -async def test_validate_input_connection_error( - hass: HomeAssistant, - mock_kiosker_api: Mock, -) -> None: - """Test validate_input with connection error.""" - - mock_kiosker_api.status.side_effect = ConnectionError("Connection failed") - - data = { - CONF_HOST: "192.168.1.100", - CONF_API_TOKEN: "test_token", - CONF_SSL: False, - CONF_VERIFY_SSL: False, - } - errors, device_id = await validate_input(hass, data) assert errors == {"base": "cannot_connect"} assert device_id is None diff --git a/tests/components/kiosker/test_init.py b/tests/components/kiosker/test_init.py index 3897bd1e2d3f1..87dc998b68f99 100644 --- a/tests/components/kiosker/test_init.py +++ b/tests/components/kiosker/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.kiosker.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -43,6 +45,7 @@ async def test_device_info( mock_config_entry: MockConfigEntry, mock_kiosker_api: MagicMock, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test device registry integration.""" await setup_integration(hass, mock_config_entry) @@ -51,38 +54,4 @@ async def test_device_info( identifiers={(DOMAIN, "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC")} ) assert device_entry is not None - assert device_entry.name == "Kiosker A98BE1CE" - assert device_entry.manufacturer == "Top North" - assert device_entry.model == "Kiosker" - assert device_entry.sw_version == "25.1.1" - assert device_entry.hw_version == "iPad Pro (18.0)" - assert device_entry.serial_number == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - - -async def test_device_identifiers_and_info( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_kiosker_api: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device identifiers and device info are set correctly.""" - mock_status = mock_kiosker_api.status.return_value - mock_status.device_id = "TEST_DEVICE_123" - mock_status.model = "iPad Mini" - mock_status.os_version = "17.5" - mock_status.app_name = "Kiosker" - mock_status.app_version = "24.1.0" - - await setup_integration(hass, mock_config_entry) - - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, "TEST_DEVICE_123")} - ) - assert device_entry is not None - assert device_entry.name == "Kiosker TEST_DEV" - assert device_entry.manufacturer == "Top North" - assert device_entry.model == "Kiosker" - assert device_entry.sw_version == "24.1.0" - assert device_entry.hw_version == "iPad Mini (17.5)" - assert device_entry.serial_number == "TEST_DEVICE_123" - assert device_entry.identifiers == {(DOMAIN, "TEST_DEVICE_123")} + assert device_entry == snapshot diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index 971a81e3b57b4..4536b3850336e 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -2,407 +2,25 @@ from __future__ import annotations -from datetime import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.kiosker.coordinator import KioskerData from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry - - -async def test_sensors_setup( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test setting up all sensors.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat( - "2025-01-01T12:00:00+00:00" - ) - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") - - mock_screensaver = MagicMock() - mock_screensaver.visible = True - - mock_blackout = MagicMock() - mock_blackout.visible = True - mock_blackout.text = "Test blackout" - # Mock dataclass fields for extra attributes - mock_blackout.__dataclass_fields__ = { - "visible": None, - "text": None, - } - - mock_api.status.return_value = mock_status - mock_api.screensaver_get_state.return_value = mock_screensaver - mock_api.blackout_get.return_value = mock_blackout - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=mock_screensaver, - blackout=mock_blackout, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Check that all sensor entities were created - expected_sensors = [ - "sensor.kiosker_a98be1ce_battery", - "sensor.kiosker_a98be1ce_last_interaction", - "sensor.kiosker_a98be1ce_last_motion", - "sensor.kiosker_a98be1ce_ambient_light", - ] - - for sensor_id in expected_sensors: - state = hass.states.get(sensor_id) - assert state is not None, f"Sensor {sensor_id} was not created" - - # Check entity registry - entity_registry = er.async_get(hass) - for sensor_id in expected_sensors: - entity = entity_registry.async_get(sensor_id) - assert entity is not None, f"Sensor {sensor_id} not in entity registry" - - -async def test_battery_level_sensor( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test battery level sensor.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 42 - mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat( - "2025-01-01T12:00:00+00:00" - ) - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check battery level sensor - state = hass.states.get("sensor.kiosker_a98be1ce_battery") - assert state is not None - assert state.state == "42" - assert state.attributes["unit_of_measurement"] == "%" - assert state.attributes["device_class"] == "battery" - assert state.attributes["state_class"] == "measurement" - - -async def test_last_interaction_sensor( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test last interaction sensor.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat( - "2026-03-03T22:41:09+00:00" - ) - mock_status.last_motion = datetime.fromisoformat("2025-03-03T22:40:09+00:00") - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) +from . import setup_integration - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() +from tests.common import MockConfigEntry, snapshot_platform - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - # Check last interaction sensor - state = hass.states.get("sensor.kiosker_a98be1ce_last_interaction") - assert state is not None - assert state.state == "2026-03-03T22:41:09+00:00" - assert state.attributes["device_class"] == "timestamp" - - -async def test_last_motion_sensor( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test last motion sensor.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat( - "2025-01-01T12:00:00+00:00" - ) - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check last motion sensor - state = hass.states.get("sensor.kiosker_a98be1ce_last_motion") - assert state is not None - assert state.state == "2025-01-01T11:55:00+00:00" - assert state.attributes["device_class"] == "timestamp" - - -async def test_ambient_light_sensor( - hass: HomeAssistant, mock_config_entry: MockConfigEntry +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Test ambient light sensor.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data - mock_status = MagicMock() - mock_status.device_id = "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat( - "2025-01-01T12:00:00+00:00" - ) - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") - mock_status.ambient_light = 2.6 - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check ambient light sensor - state = hass.states.get("sensor.kiosker_a98be1ce_ambient_light") - assert state is not None - assert state.state == "2.6" - assert state.attributes["state_class"] == "measurement" - # Verify no unit of measurement (unit-less sensor) - assert "unit_of_measurement" not in state.attributes - - -async def test_sensor_unique_ids( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test sensor unique ID generation.""" - with patch( - "homeassistant.components.kiosker.coordinator.KioskerAPI" - ) as mock_api_class: - # Setup mock API - mock_api = MagicMock() - mock_api.host = "10.0.1.5" - mock_api_class.return_value = mock_api - - # Setup mock data with custom device ID - mock_status = MagicMock() - mock_status.device_id = "TEST_SENSOR_ID" - mock_status.model = "iPad Pro" - mock_status.os_version = "18.0" - mock_status.app_name = "Kiosker" - mock_status.app_version = "25.1.1" - mock_status.battery_level = 85 - mock_status.battery_state = "charging" - mock_status.last_interaction = datetime.fromisoformat( - "2025-01-01T12:00:00+00:00" - ) - mock_status.last_motion = datetime.fromisoformat("2025-01-01T11:55:00+00:00") - - mock_api.status.return_value = mock_status - - # Add the config entry - mock_config_entry.add_to_hass(hass) - - # Setup the integration - with patch( - "homeassistant.components.kiosker.coordinator.KioskerDataUpdateCoordinator._async_update_data" - ) as mock_update: - mock_update.return_value = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Manually set coordinator data and trigger update - coordinator = mock_config_entry.runtime_data - coordinator.data = KioskerData( - status=mock_status, - screensaver=None, - blackout=None, - ) - coordinator.async_update_listeners() - await hass.async_block_till_done() - - # Check that sensor entities have correct unique IDs - entity_registry = er.async_get(hass) - - expected_unique_ids = [ - ("sensor.kiosker_test_sen_battery", "TEST_SENSOR_ID_batteryLevel"), - ("sensor.kiosker_test_sen_last_interaction", "TEST_SENSOR_ID_lastInteraction"), - ("sensor.kiosker_test_sen_last_motion", "TEST_SENSOR_ID_lastMotion"), - ("sensor.kiosker_test_sen_ambient_light", "TEST_SENSOR_ID_ambientLight"), - ] - - for entity_id, expected_unique_id in expected_unique_ids: - entity = entity_registry.async_get(entity_id) - assert entity is not None, f"Entity {entity_id} not found" - assert entity.unique_id == expected_unique_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From acb62db17d42861d0055b44d2b9269b7571008dd Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:11:53 +0200 Subject: [PATCH 54/69] Update homeassistant/components/kiosker/entity.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/kiosker/entity.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 39a0f0f0c8387..3dee8f4b0d23e 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -59,7 +59,3 @@ def __init__( f"{device_id}_{description.key}" if description else f"{device_id}" ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available From 1c89cfcbd0262186174ac161eb066dba9c9bfb23 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:13:34 +0200 Subject: [PATCH 55/69] Update homeassistant/components/kiosker/entity.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/kiosker/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 3dee8f4b0d23e..fe32d3297dbab 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -56,6 +56,6 @@ def __init__( ) self._attr_unique_id = ( - f"{device_id}_{description.key}" if description else f"{device_id}" + f"{device_id}_{description.key}" ) From 4e89cf35d9c035ecc95e74859cc1d7f3847bc498 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:14:04 +0200 Subject: [PATCH 56/69] Update homeassistant/components/kiosker/sensor.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/kiosker/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index 989b8de6e661c..a07dac04aa7bb 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -71,7 +71,6 @@ async def async_setup_entry( """Set up Kiosker sensors based on a config entry.""" coordinator = entry.runtime_data - # Create all sensors - they will handle missing data gracefully async_add_entities( KioskerSensor(coordinator, description) for description in SENSORS ) From 6bbd57a94f27f6a05f19362e4d992637d0e99494 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:15:21 +0200 Subject: [PATCH 57/69] Update homeassistant/components/kiosker/sensor.py Co-authored-by: Joost Lekkerkerker --- homeassistant/components/kiosker/sensor.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index a07dac04aa7bb..dca181cabf72c 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -81,13 +81,6 @@ class KioskerSensor(KioskerEntity, SensorEntity): entity_description: KioskerSensorEntityDescription - def __init__( - self, - coordinator: KioskerDataUpdateCoordinator, - description: KioskerSensorEntityDescription, - ) -> None: - """Initialize the sensor entity.""" - super().__init__(coordinator, description) @property def native_value(self) -> StateType | datetime | None: From fc1c38a6df9b75ff2f581246eea9d7e502213f05 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:15:42 +0200 Subject: [PATCH 58/69] Update homeassistant/components/kiosker/strings.json Co-authored-by: Joost Lekkerkerker --- homeassistant/components/kiosker/strings.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 5402465ccf7e6..4bee2a50045f2 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -52,9 +52,6 @@ "ambient_light": { "name": "Ambient light" }, - "battery_level": { - "name": "Battery level" - }, "last_interaction": { "name": "Last interaction" }, From 1612e162bcdc243cd088dd988f8db2ea8f50b832 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:15:56 +0200 Subject: [PATCH 59/69] Update homeassistant/components/kiosker/icons.json Co-authored-by: Joost Lekkerkerker --- homeassistant/components/kiosker/icons.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index 171431fc8ac7c..f9341ff28a7ed 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -6,7 +6,6 @@ }, "battery_state": { "default": "mdi:lightning-bolt" - }, "blackout_state": { "default": "mdi:monitor-off" }, From daafdd6fe3b2e55e677be34e1de93594b1882175 Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:16:17 +0200 Subject: [PATCH 60/69] Update tests/components/kiosker/test_config_flow.py Co-authored-by: Joost Lekkerkerker --- tests/components/kiosker/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 96a467f518f21..9f4fa928cdb5b 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -61,7 +61,7 @@ async def test_user_flow_creates_entry( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: "192.168.1.100", From f88ed56a2d736a795cd782d455e3dc90cf8eab2c Mon Sep 17 00:00:00 2001 From: Martin Claesson <43297668+Claeysson@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:16:51 +0200 Subject: [PATCH 61/69] Update tests/components/kiosker/test_config_flow.py Co-authored-by: Joost Lekkerkerker --- tests/components/kiosker/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 9f4fa928cdb5b..ebd8476a78944 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -56,7 +56,7 @@ async def test_user_flow_creates_entry( ) -> None: """Test the full user config flow creates a config entry.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} From a22def9d82bd33e4b075589308c71ce18d04ab00 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Fri, 10 Apr 2026 22:32:13 +0200 Subject: [PATCH 62/69] Review --- homeassistant/components/kiosker/entity.py | 10 +-- homeassistant/components/kiosker/icons.json | 2 - homeassistant/components/kiosker/sensor.py | 2 - tests/components/kiosker/test_config_flow.py | 93 +++++++++++--------- 4 files changed, 54 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index fe32d3297dbab..88d5c2ceecc94 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -33,10 +33,7 @@ def __init__( os_version = status.os_version # Use uppercased truncated device ID for display purposes (device name, titles) - try: - device_id_short_display = device_id[:8].upper() - except TypeError, AttributeError: - device_id_short_display = "unknown" + device_id_short_display = device_id[:8].upper() # Set device info self._attr_device_info = DeviceInfo( @@ -55,7 +52,4 @@ def __init__( serial_number=device_id, ) - self._attr_unique_id = ( - f"{device_id}_{description.key}" - ) - + self._attr_unique_id = f"{device_id}_{description.key}" diff --git a/homeassistant/components/kiosker/icons.json b/homeassistant/components/kiosker/icons.json index f9341ff28a7ed..b40f689e28ec4 100644 --- a/homeassistant/components/kiosker/icons.json +++ b/homeassistant/components/kiosker/icons.json @@ -4,8 +4,6 @@ "ambient_light": { "default": "mdi:brightness-6" }, - "battery_state": { - "default": "mdi:lightning-bolt" "blackout_state": { "default": "mdi:monitor-off" }, diff --git a/homeassistant/components/kiosker/sensor.py b/homeassistant/components/kiosker/sensor.py index dca181cabf72c..76457b0c088fb 100644 --- a/homeassistant/components/kiosker/sensor.py +++ b/homeassistant/components/kiosker/sensor.py @@ -20,7 +20,6 @@ from homeassistant.helpers.typing import StateType from . import KioskerConfigEntry -from .coordinator import KioskerDataUpdateCoordinator from .entity import KioskerEntity # Coordinator-based platform; no per-entity polling concurrency needed @@ -81,7 +80,6 @@ class KioskerSensor(KioskerEntity, SensorEntity): entity_description: KioskerSensorEntityDescription - @property def native_value(self) -> StateType | datetime | None: """Return the native value of the sensor.""" diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index ebd8476a78944..02083a656d24d 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -14,7 +14,6 @@ import pytest from homeassistant import config_entries -from homeassistant.components.kiosker.config_flow import validate_input from homeassistant.components.kiosker.const import CONF_API_TOKEN, DOMAIN from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant @@ -56,12 +55,12 @@ async def test_user_flow_creates_entry( ) -> None: """Test the full user config flow creates a config entry.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure( + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_HOST: "192.168.1.100", @@ -138,7 +137,7 @@ async def test_zeroconf( hass: HomeAssistant, mock_kiosker_api: MagicMock, ) -> None: - """Test the full zeroconf discovery flow creates a config entry.""" + """Test the zeroconf discovery happy flow creates a config entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -153,7 +152,36 @@ async def test_zeroconf( schema_keys = list(result["data_schema"].schema.keys()) assert any(key.schema == CONF_API_TOKEN for key in schema_keys) - # Test error handling + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: "test-token", + CONF_VERIFY_SSL: False, + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Kiosker A98BE1CE" + assert result2["data"] == { + CONF_HOST: "192.168.1.39", + CONF_API_TOKEN: "test-token", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + } + assert result2["result"].unique_id == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" + + +async def test_zeroconf_error_and_recovery( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, +) -> None: + """Test zeroconf discovery handles errors and recovers.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + mock_kiosker_api.status.side_effect = ConnectionError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -165,7 +193,6 @@ async def test_zeroconf( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} - # Test recovery mock_kiosker_api.status.side_effect = None result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -175,14 +202,6 @@ async def test_zeroconf( }, ) assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Kiosker A98BE1CE" - assert result3["data"] == { - CONF_HOST: "192.168.1.39", - CONF_API_TOKEN: "test-token", - CONF_SSL: True, - CONF_VERIFY_SSL: False, - } - assert result3["result"].unique_id == "A98BE1CE-5FE7-4A8D-B2C3-123456789ABC" async def test_zeroconf_no_uuid(hass: HomeAssistant) -> None: @@ -208,7 +227,6 @@ async def test_abort_if_already_configured( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_kiosker_api.status.side_effect = ConnectionError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -218,21 +236,8 @@ async def test_abort_if_already_configured( CONF_VERIFY_SSL: False, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - mock_kiosker_api.status.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "192.168.1.200", - CONF_API_TOKEN: "test-token", - CONF_SSL: False, - CONF_VERIFY_SSL: False, - }, - ) - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "already_configured" + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" async def test_zeroconf_abort_if_already_configured( @@ -251,20 +256,26 @@ async def test_zeroconf_abort_if_already_configured( assert result["reason"] == "already_configured" -async def test_validate_input_no_device_id( +async def test_user_flow_no_device_id( hass: HomeAssistant, + mock_setup_entry: AsyncMock, mock_kiosker_api: MagicMock, ) -> None: - """Test validate_input returns cannot_connect when device has no device_id.""" + """Test user flow shows cannot_connect error when device reports no device ID.""" mock_kiosker_api.status.return_value.device_id = None - data = { - CONF_HOST: "10.0.1.5", - CONF_API_TOKEN: "test_token", - CONF_SSL: False, - CONF_VERIFY_SSL: False, - } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - errors, device_id = await validate_input(hass, data) - assert errors == {"base": "cannot_connect"} - assert device_id is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "10.0.1.5", + CONF_API_TOKEN: "test_token", + CONF_SSL: False, + CONF_VERIFY_SSL: False, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} From bc1e5fdae5015550575d9118d52a87fbcd92706c Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Fri, 10 Apr 2026 23:09:47 +0200 Subject: [PATCH 63/69] Removed model and manufacturer --- homeassistant/components/kiosker/entity.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/kiosker/entity.py b/homeassistant/components/kiosker/entity.py index 88d5c2ceecc94..8abf4fc8c61fd 100644 --- a/homeassistant/components/kiosker/entity.py +++ b/homeassistant/components/kiosker/entity.py @@ -39,9 +39,7 @@ def __init__( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, name=(f"Kiosker {device_id_short_display}"), - manufacturer="Top North", - model=app_name, - sw_version=app_version, + sw_version=(f"{app_name} {app_version}"), hw_version=( None if model is None From 195a89a0358d2531fa17c13b8bf4dfc9c72d6958 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 11 Apr 2026 00:50:33 +0200 Subject: [PATCH 64/69] Test snapshots --- .../kiosker/snapshots/test_init.ambr | 6 +++--- .../kiosker/snapshots/test_sensor.ambr | 20 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/components/kiosker/snapshots/test_init.ambr b/tests/components/kiosker/snapshots/test_init.ambr index f179210737120..403237a6f51a3 100644 --- a/tests/components/kiosker/snapshots/test_init.ambr +++ b/tests/components/kiosker/snapshots/test_init.ambr @@ -19,14 +19,14 @@ }), 'labels': set({ }), - 'manufacturer': 'Top North', - 'model': 'Kiosker', + 'manufacturer': None, + 'model': None, 'model_id': None, 'name': 'Kiosker A98BE1CE', 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC', - 'sw_version': '25.1.1', + 'sw_version': 'Kiosker 25.1.1', 'via_device_id': None, }) # --- diff --git a/tests/components/kiosker/snapshots/test_sensor.ambr b/tests/components/kiosker/snapshots/test_sensor.ambr index f6893ee18412d..f7d669fda853d 100644 --- a/tests/components/kiosker/snapshots/test_sensor.ambr +++ b/tests/components/kiosker/snapshots/test_sensor.ambr @@ -1,8 +1,9 @@ # serializer version: 1 # name: test_all_entities[sensor.kiosker_a98be1ce_ambient_light-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': dict({ 'state_class': , @@ -53,8 +54,9 @@ # --- # name: test_all_entities[sensor.kiosker_a98be1ce_battery-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': dict({ 'state_class': , @@ -107,8 +109,9 @@ # --- # name: test_all_entities[sensor.kiosker_a98be1ce_last_interaction-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': None, 'config_entry_id': , @@ -157,8 +160,9 @@ # --- # name: test_all_entities[sensor.kiosker_a98be1ce_last_motion-entry] EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), + 'aliases': list([ + None, + ]), 'area_id': None, 'capabilities': None, 'config_entry_id': , From 498ece4285b9da436f757ddd04dbfe71ba422475 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 11 Apr 2026 14:39:45 +0200 Subject: [PATCH 65/69] Changed ZeroConf title --- homeassistant/components/kiosker/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 3dc7face13dd2..b4ab16750a418 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -126,6 +126,8 @@ async def async_step_zeroconf( ) -> ConfigFlowResult: """Handle zeroconf discovery.""" host = discovery_info.host + hostname = discovery_info.hostname + name = hostname.rstrip(".").removesuffix(".local") # Extract device information from zeroconf properties properties = discovery_info.properties @@ -136,7 +138,7 @@ async def async_step_zeroconf( # Use device_id from zeroconf if device_id: - device_name = f"{app_name} ({device_id[:8].upper()})" + device_name = f"{name or host or app_name} ({device_id[:8].upper()})" unique_id = device_id else: _LOGGER.debug("Zeroconf properties did not include a valid device_id") From 540bf40c57f205b7c8bdc64653634a2dfdd7c4a0 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 11 Apr 2026 15:07:11 +0200 Subject: [PATCH 66/69] Fixed failing test --- tests/components/kiosker/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 02083a656d24d..5f798c15b25c3 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -25,7 +25,7 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ip_address=ip_address("192.168.1.39"), ip_addresses=[ip_address("192.168.1.39")], - hostname="kiosker-device.local.", + hostname="python-test-device.local.", name="Kiosker Device._kiosker._tcp.local.", port=8081, properties={ @@ -146,7 +146,7 @@ async def test_zeroconf( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "zeroconf_confirm" assert result["description_placeholders"] == { - "name": "Kiosker (A98BE1CE)", + "name": "python-test-device (A98BE1CE)", "host": "192.168.1.39", } schema_keys = list(result["data_schema"].schema.keys()) From da7fd8552090d340fb180ca46782ce5556e1fa8a Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 11 Apr 2026 16:17:10 +0200 Subject: [PATCH 67/69] Only test sensor plaform in test_sensor.py --- tests/components/kiosker/test_sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/components/kiosker/test_sensor.py b/tests/components/kiosker/test_sensor.py index 4536b3850336e..ee65e295018f2 100644 --- a/tests/components/kiosker/test_sensor.py +++ b/tests/components/kiosker/test_sensor.py @@ -2,10 +2,11 @@ from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,5 +23,6 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - await setup_integration(hass, mock_config_entry) + with patch("homeassistant.components.kiosker._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From dd2a9ff8843f8e9ab5129fb7a8488a17a8f590e8 Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 11 Apr 2026 22:31:34 +0200 Subject: [PATCH 68/69] Quality scale review --- .../components/kiosker/quality_scale.yaml | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 4e4e9e897ddc7..b2e35eb4ed1f6 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -45,23 +45,27 @@ rules: devices: done discovery-update-info: todo discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: todo - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo - entity-translations: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Integration does not create or remove devices dynamically after setup + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: todo - stale-devices: todo + stale-devices: + status: exempt + comment: Integration does not create or remove devices dynamically after setup # Platinum async-dependency: todo From 4c1c2f51e4ba8cc54929d36f2de7c756997c8ddf Mon Sep 17 00:00:00 2001 From: Martin Claesson Date: Sat, 11 Apr 2026 22:34:01 +0200 Subject: [PATCH 69/69] Quality scale review --- homeassistant/components/kiosker/quality_scale.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index b2e35eb4ed1f6..36e0f730ed92c 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -30,9 +30,7 @@ rules: status: exempt comment: Integration does not provide custom actions config-entry-unloading: done - docs-configuration-parameters: - status: exempt - comment: Integration does not provide configuration options (no options flow) + docs-configuration-parameters: done docs-installation-parameters: done entity-unavailable: done integration-owner: done