-
-
Notifications
You must be signed in to change notification settings - Fork 37.2k
Add Kiosker integration #164543
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Add Kiosker integration #164543
Changes from 38 commits
65542a1
1181ddf
a2b457c
ccd8fdf
7f8ee6d
a6752b2
b188faa
8f844a5
1b04aba
6a49e95
9cf95a8
5730542
80d7eab
dc6428a
0f51804
43c1e8b
5d70644
b40654f
5964bcb
c2de064
1f281f9
0c793dd
092f6c0
fa0e4bf
de78917
4e631cc
20e4b35
6efcecc
b1f1619
1ec92e3
b9b0054
735344d
e776c5e
dcc0589
184244b
f63ecac
8aaa161
ee12318
f9652f9
91aaced
13b2f74
effcd11
a83ea23
4330dfd
8ad2978
cbe3c47
13abab9
2ac64ad
0279c90
3b34b09
8b912a5
fae397a
6bb8727
acb62db
1c89cfc
4e89cf3
6bbd57a
fc1c38a
1612e16
daafdd6
f88ed56
a22def9
bc1e5fd
eefec9d
195a89a
498ece4
540bf40
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| """The Kiosker integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from homeassistant.const import Platform | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator | ||
|
|
||
| _PLATFORMS: list[Platform] = [Platform.SENSOR] | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: | ||
| """Set up Kiosker from a config entry.""" | ||
|
|
||
| coordinator = KioskerDataUpdateCoordinator(hass, entry) | ||
|
|
||
| await coordinator.async_config_entry_first_refresh() | ||
|
|
||
| entry.runtime_data = coordinator | ||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| """Config flow for the Kiosker integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| from kiosker import ( | ||
| AuthenticationError, | ||
| BadRequestError, | ||
| ConnectionError, | ||
| IPAuthenticationError, | ||
| KioskerAPI, | ||
| PingError, | ||
| TLSVerificationError, | ||
| ) | ||
| 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.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 | ||
|
|
||
| _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, | ||
| } | ||
Claeysson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
| STEP_ZEROCONF_CONFIRM_DATA_SCHEMA = vol.Schema( | ||
| { | ||
| vol.Required(CONF_API_TOKEN): str, | ||
Claeysson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| vol.Optional(CONF_VERIFY_SSL, 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], | ||
| port=data[CONF_PORT], | ||
| token=data[CONF_API_TOKEN], | ||
| ssl=data[CONF_SSL], | ||
| verify=data[CONF_VERIFY_SSL], | ||
| ) | ||
|
|
||
| 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: | ||
| _LOGGER.exception("Unexpected exception while connecting to Kiosker") | ||
| raise CannotConnect from exc | ||
Claeysson marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| # 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}", "device_id": device_id} | ||
|
|
||
|
|
||
| class KioskerConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for Kiosker.""" | ||
|
|
||
Claeysson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| VERSION = 1 | ||
| MINOR_VERSION = 1 | ||
|
|
||
| def __init__(self) -> None: | ||
| """Initialize the config flow.""" | ||
|
|
||
| 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 | ||
| ) -> ConfigFlowResult: | ||
| """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: | ||
| # Use device ID as unique identifier | ||
| await self.async_set_unique_id( | ||
| info["device_id"], raise_on_progress=False | ||
| ) | ||
| self._abort_if_unique_id_configured() | ||
|
|
||
| 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", "") | ||
| ssl = properties.get("ssl", "false").lower() == "true" | ||
|
|
||
| # Use UUID from zeroconf | ||
| if uuid: | ||
| device_name = f"{app_name} ({uuid[:8].upper()})" | ||
| unique_id = uuid | ||
| else: | ||
| _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 | ||
| await self.async_set_unique_id(unique_id) | ||
Claeysson marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 | ||
| self._discovered_host = host | ||
| 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() | ||
|
|
||
| async def async_step_zeroconf_confirm( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Handle zeroconf confirmation.""" | ||
| errors: dict[str, str] = {} | ||
|
|
||
| if user_input is not None and CONF_API_TOKEN in user_input: | ||
Claeysson marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| # 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: ssl, | ||
| 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) | ||
|
|
||
| # Show form to get API token for discovered device | ||
| return self.async_show_form( | ||
| step_id="zeroconf_confirm", | ||
| data_schema=STEP_ZEROCONF_CONFIRM_DATA_SCHEMA, | ||
| 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.""" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| """Constants for the Kiosker integration.""" | ||
|
|
||
| DOMAIN = "kiosker" | ||
|
|
||
| # Configuration keys | ||
| CONF_API_TOKEN = "api_token" | ||
|
|
||
| # Default values | ||
| DEFAULT_PORT = 8081 | ||
| POLL_INTERVAL = 15 | ||
| DEFAULT_SSL = False | ||
| DEFAULT_SSL_VERIFY = False |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| """DataUpdateCoordinator for Kiosker.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from datetime import timedelta | ||
| import logging | ||
|
|
||
| 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, 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 | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| type KioskerConfigEntry = ConfigEntry[KioskerDataUpdateCoordinator] | ||
|
|
||
|
|
||
| @dataclass | ||
| class KioskerData: | ||
| """Data structure for Kiosker integration.""" | ||
|
|
||
| status: Status | ||
| blackout: Blackout | None | ||
| screensaver: ScreensaverState | None | ||
|
|
||
Claeysson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| class KioskerDataUpdateCoordinator(DataUpdateCoordinator[KioskerData]): | ||
| """Class to manage fetching data from the Kiosker API.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| config_entry: KioskerConfigEntry, | ||
| ) -> None: | ||
| """Initialize.""" | ||
| 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_VERIFY_SSL, False), | ||
| ) | ||
| super().__init__( | ||
| hass, | ||
| _LOGGER, | ||
| name=DOMAIN, | ||
| update_interval=timedelta(seconds=POLL_INTERVAL), | ||
| 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, blackout, screensaver = await self.hass.async_add_executor_job( | ||
| self._fetch_all_data | ||
| ) | ||
Claeysson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| except (AuthenticationError, IPAuthenticationError) as exc: | ||
| raise ConfigEntryAuthFailed("Authentication failed") from exc | ||
| except (ConnectionError, PingError) as exc: | ||
| raise UpdateFailed(f"Connection failed: {exc}") from exc | ||
| except TLSVerificationError as exc: | ||
| raise UpdateFailed(f"TLS verification failed: {exc}") from exc | ||
| except BadRequestError as exc: | ||
| raise UpdateFailed(f"Bad request: {exc}") from exc | ||
| except (OSError, TimeoutError) as exc: | ||
| raise UpdateFailed(f"Connection timeout: {exc}") from exc | ||
| except Exception as exc: | ||
| _LOGGER.debug("Unexpected error updating Kiosker data") | ||
Claeysson marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| raise UpdateFailed(f"Unexpected error: {exc}") from exc | ||
|
|
||
| return KioskerData( | ||
| status=status, | ||
| blackout=blackout, | ||
| screensaver=screensaver, | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.