"""KNX Websocket API.""" from collections.abc import Awaitable, Callable from contextlib import ExitStack from datetime import timedelta from functools import wraps import inspect from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel from knx_telegram_store import KnxTelegramStoreException, TelegramQuery import voluptuous as vol from xknx.telegram import Telegram from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api from homeassistant.components.frontend import async_panel_exists from homeassistant.components.http import StaticPathConfig from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import UNDEFINED from homeassistant.util import dt as dt_util from homeassistant.util.ulid import ulid_now from .const import ( CONF_KNX_TELEGRAM_DB_LOAD_HOURS, DOMAIN, KNX_MODULE_KEY, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, SIGNAL_KNX_TELEGRAM, SUPPORTED_PLATFORMS_UI, ) from .dpt import get_supported_dpts from .storage.config_store import ConfigStoreException from .storage.const import CONF_DATA from .storage.entity_store_schema import ( CREATE_ENTITY_BASE_SCHEMA, UPDATE_ENTITY_BASE_SCHEMA, ) from .storage.entity_store_validation import ( EntityStoreValidationException, EntityStoreValidationSuccess, validate_entity_data, ) from .storage.expose_controller import validate_expose_data from .storage.serialize import get_serialized_schema from .storage.time_server import validate_time_server_data from .telegrams import TelegramDict if TYPE_CHECKING: from .knx_module import KNXModule URL_BASE: Final = "/knx_static" async def register_panel(hass: HomeAssistant) -> None: """Register the KNX Panel and Websocket API.""" websocket_api.async_register_command(hass, ws_get_base_data) websocket_api.async_register_command(hass, ws_project_file_process) websocket_api.async_register_command(hass, ws_project_file_remove) websocket_api.async_register_command(hass, ws_group_monitor_info) websocket_api.async_register_command(hass, ws_group_telegrams) websocket_api.async_register_command(hass, ws_query_telegrams) websocket_api.async_register_command(hass, ws_subscribe_telegram) websocket_api.async_register_command(hass, ws_get_knx_project) websocket_api.async_register_command(hass, ws_validate_entity) websocket_api.async_register_command(hass, ws_create_entity) websocket_api.async_register_command(hass, ws_update_entity) websocket_api.async_register_command(hass, ws_delete_entity) websocket_api.async_register_command(hass, ws_get_entity_config) websocket_api.async_register_command(hass, ws_get_entities_by_group) websocket_api.async_register_command(hass, ws_create_device) websocket_api.async_register_command(hass, ws_get_schema) websocket_api.async_register_command(hass, ws_get_time_server_config) websocket_api.async_register_command(hass, ws_update_time_server_config) websocket_api.async_register_command(hass, ws_get_expose_groups) websocket_api.async_register_command(hass, ws_get_expose_config) websocket_api.async_register_command(hass, ws_update_expose) websocket_api.async_register_command(hass, ws_delete_expose) websocket_api.async_register_command(hass, ws_validate_expose) if not async_panel_exists(hass, DOMAIN): await hass.http.async_register_static_paths( [ StaticPathConfig( URL_BASE, path=knx_panel.locate_dir(), cache_headers=knx_panel.is_prod_build, ) ] ) await panel_custom.async_register_panel( hass=hass, frontend_url_path=DOMAIN, webcomponent_name=knx_panel.webcomponent_name, module_url=f"{URL_BASE}/{knx_panel.entrypoint_js}", embed_iframe=True, require_admin=True, ) type KnxWebSocketCommandHandler = Callable[ [HomeAssistant, KNXModule, websocket_api.ActiveConnection, dict[str, Any]], None ] type KnxAsyncWebSocketCommandHandler = Callable[ [HomeAssistant, KNXModule, websocket_api.ActiveConnection, dict[str, Any]], Awaitable[None], ] @overload def provide_knx( func: KnxAsyncWebSocketCommandHandler, ) -> websocket_api.const.AsyncWebSocketCommandHandler: ... @overload def provide_knx( func: KnxWebSocketCommandHandler, ) -> websocket_api.const.WebSocketCommandHandler: ... def provide_knx( func: KnxAsyncWebSocketCommandHandler | KnxWebSocketCommandHandler, ) -> ( websocket_api.const.AsyncWebSocketCommandHandler | websocket_api.const.WebSocketCommandHandler ): """Websocket decorator to provide a KNXModule instance.""" def _send_not_loaded_error( connection: websocket_api.ActiveConnection, msg_id: int ) -> None: connection.send_error( msg_id, websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "KNX integration not loaded.", ) if inspect.iscoroutinefunction(func): @wraps(func) async def with_knx( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Add KNX Module to call function.""" try: knx = hass.data[KNX_MODULE_KEY] except KeyError: _send_not_loaded_error(connection, msg["id"]) return await func(hass, knx, connection, msg) else: @wraps(func) def with_knx( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Add KNX Module to call function.""" try: knx = hass.data[KNX_MODULE_KEY] except KeyError: _send_not_loaded_error(connection, msg["id"]) return func(hass, knx, connection, msg) return with_knx @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_base_data", } ) @provide_knx @callback def ws_get_base_data( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle get info command.""" _project_info = None if project_info := knx.project.info: _project_info = { "name": project_info["name"], "last_modified": project_info["last_modified"], "tool_version": project_info["tool_version"], "xknxproject_version": project_info["xknxproject_version"], } connection_info = { "version": knx.xknx.version, "connected": knx.xknx.connection_manager.connected.is_set(), "current_address": str(knx.xknx.current_address), "telegram_backend": ( "sqlite" if knx.telegrams.store is not None else "unknown" ), "telegram_retention": knx.telegrams.store.retention_days if knx.telegrams.store is not None else None, "telegram_max_count": knx.telegrams.store.max_telegrams if knx.telegrams.store is not None else None, } connection.send_result( msg["id"], { "connection_info": connection_info, "dpt_metadata": get_supported_dpts(), "project_info": _project_info, "supported_platforms": sorted(SUPPORTED_PLATFORMS_UI), }, ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_knx_project", } ) @websocket_api.async_response @provide_knx async def ws_get_knx_project( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle get KNX project.""" knxproject = await knx.project.get_knxproject() connection.send_result( msg["id"], knxproject, ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/project_file_process", vol.Required("file_id"): str, vol.Required("password"): str, } ) @websocket_api.async_response @provide_knx async def ws_project_file_process( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle get info command.""" try: await knx.project.process_project_file( xknx=knx.xknx, file_id=msg["file_id"], password=msg["password"], ) except (ValueError, XknxProjectException) as err: # ValueError could raise from file_upload integration connection.send_error( msg["id"], websocket_api.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result(msg["id"]) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/project_file_remove", } ) @websocket_api.async_response @provide_knx async def ws_project_file_remove( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle get info command.""" await knx.project.remove_project_file() connection.send_result(msg["id"]) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/group_monitor_info", } ) @websocket_api.async_response @provide_knx async def ws_group_monitor_info( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle get info command of group monitor.""" load_hours = knx.entry.options[CONF_KNX_TELEGRAM_DB_LOAD_HOURS] start_time = dt_util.now() - timedelta(hours=load_hours) query = TelegramQuery(start_time=start_time, order_descending=True) if knx.telegrams.store is None: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Telegram storage backend not initialized. " "Check logs/Repairs for initialization errors.", ) return try: result = await knx.telegrams.store.query(query, flush_first=True) except KnxTelegramStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, f"Database error: {err}", ) return connection.send_result( msg["id"], { "project_loaded": knx.project.loaded, "recent_telegrams": [ knx.telegrams.model_to_dict(t) for t in result.telegrams ], }, ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/group_telegrams", } ) @provide_knx @callback def ws_group_telegrams( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle get group telegrams command.""" connection.send_result( msg["id"], knx.telegrams.last_ga_telegrams, ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/query_telegrams", vol.Optional("sources"): [str], vol.Optional("destinations"): [str], vol.Optional("telegram_types"): [str], vol.Optional("directions"): [str], vol.Optional("dpt_mains"): [vol.Coerce(int)], vol.Optional("start_time"): cv.datetime, vol.Optional("end_time"): cv.datetime, vol.Optional("delta_before_ms"): vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional("delta_after_ms"): vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional("limit"): vol.All(vol.Coerce(int), vol.Range(min=1, max=100_000)), vol.Optional("offset"): vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional("order_descending"): bool, } ) @websocket_api.async_response @provide_knx async def ws_query_telegrams( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Handle query telegrams command.""" start_time = msg.get("start_time") if start_time is None: load_hours = knx.entry.options[CONF_KNX_TELEGRAM_DB_LOAD_HOURS] start_time = dt_util.now() - timedelta(hours=load_hours) query = TelegramQuery( sources=msg.get("sources", []), destinations=msg.get("destinations", []), telegram_types=msg.get("telegram_types", []), directions=msg.get("directions", []), dpt_mains=msg.get("dpt_mains", []), start_time=start_time, end_time=msg.get("end_time"), delta_before_ms=msg.get("delta_before_ms", 0), delta_after_ms=msg.get("delta_after_ms", 0), limit=msg.get("limit", 100_000), offset=msg.get("offset", 0), order_descending=msg.get("order_descending", True), ) if knx.telegrams.store is None: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Telegram storage backend not initialized. " "Check logs/Repairs for initialization errors.", ) return try: result = await knx.telegrams.store.query(query, flush_first=True) except KnxTelegramStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, f"Database error: {err}", ) return connection.send_result( msg["id"], { "telegrams": [knx.telegrams.model_to_dict(t) for t in result.telegrams], "total_count": result.total_count, "limit_reached": result.limit_reached, }, ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/subscribe_telegrams", } ) @callback def ws_subscribe_telegram( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Subscribe to incoming and outgoing KNX telegrams.""" @callback def forward_telegram(_telegram: Telegram, telegram_dict: TelegramDict) -> None: """Forward telegram to websocket subscription.""" connection.send_event( msg["id"], telegram_dict, ) stack = ExitStack() stack.callback( async_dispatcher_connect( hass, signal=SIGNAL_KNX_TELEGRAM, target=forward_telegram, ) ) stack.callback( async_dispatcher_connect( hass, signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, target=forward_telegram, ) ) connection.subscriptions[msg["id"]] = stack.close connection.send_result(msg["id"]) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/validate_entity", **CREATE_ENTITY_BASE_SCHEMA, } ) @callback def ws_validate_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Validate entity data.""" try: validate_entity_data(msg) except EntityStoreValidationException as exc: connection.send_result(msg["id"], exc.validation_error) return connection.send_result( msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_schema", vol.Required(CONF_PLATFORM): vol.Coerce(Platform), } ) @websocket_api.async_response async def ws_get_schema( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Provide serialized schema for platform.""" if schema := get_serialized_schema(msg[CONF_PLATFORM]): connection.send_result(msg["id"], schema) return connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Unknown platform" ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/create_entity", **CREATE_ENTITY_BASE_SCHEMA, } ) @websocket_api.async_response @provide_knx async def ws_create_entity( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Create entity in entity store and load it.""" try: validated_data = validate_entity_data(msg) except EntityStoreValidationException as exc: connection.send_result(msg["id"], exc.validation_error) return try: entity_id = await knx.config_store.create_entity( # use validation result so defaults are applied validated_data[CONF_PLATFORM], validated_data[CONF_DATA], ) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result( msg["id"], EntityStoreValidationSuccess(success=True, entity_id=entity_id) ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/update_entity", **UPDATE_ENTITY_BASE_SCHEMA, } ) @websocket_api.async_response @provide_knx async def ws_update_entity( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Update entity in entity store and reload it.""" try: validated_data = validate_entity_data(msg) except EntityStoreValidationException as exc: connection.send_result(msg["id"], exc.validation_error) return try: await knx.config_store.update_entity( validated_data[CONF_PLATFORM], validated_data[CONF_ENTITY_ID], validated_data[CONF_DATA], ) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result( msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/delete_entity", vol.Required(CONF_ENTITY_ID): str, } ) @websocket_api.async_response @provide_knx async def ws_delete_entity( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Delete entity from entity store and remove it.""" try: await knx.config_store.delete_entity(msg[CONF_ENTITY_ID]) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result(msg["id"]) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_entities_by_group", } ) @provide_knx @callback def ws_get_entities_by_group( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Get entities by group address.""" data = { str(ga): identifiers for ga, identifiers in knx.group_address_entities.items() } connection.send_result(msg["id"], data) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_entity_config", vol.Required(CONF_ENTITY_ID): str, } ) @provide_knx @callback def ws_get_entity_config( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Get entity configuration from entity store.""" try: config_info = knx.config_store.get_entity_config(msg[CONF_ENTITY_ID]) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result(msg["id"], config_info) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/create_device", vol.Required("name"): str, vol.Optional("area_id"): str, } ) @provide_knx @callback def ws_create_device( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Create a new KNX device.""" identifier = f"knx_vdev_{ulid_now()}" device_registry = dr.async_get(hass) _device = device_registry.async_get_or_create( config_entry_id=knx.entry.entry_id, manufacturer="KNX", name=msg["name"], identifiers={(DOMAIN, identifier)}, ) device_registry.async_update_device( _device.id, area_id=msg.get("area_id") or UNDEFINED, configuration_url=f"homeassistant://knx/entities/view?device_id={_device.id}", ) connection.send_result(msg["id"], _device.dict_repr) ######## # Expose ######## @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_expose_groups", } ) @provide_knx @callback def ws_get_expose_groups( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Get exposes from config store.""" connection.send_result(msg["id"], knx.config_store.get_expose_groups()) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_expose_config", vol.Required("entity_id"): str, } ) @provide_knx @callback def ws_get_expose_config( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Get expose configuration from config store.""" connection.send_result( msg["id"], knx.config_store.get_expose_config(msg["entity_id"]) ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/update_expose", vol.Required("entity_id"): str, vol.Required("data"): dict, # validation done in handler } ) @websocket_api.async_response @provide_knx async def ws_update_expose( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Update expose configuration in config store.""" try: validated_data = validate_expose_data(msg) except EntityStoreValidationException as exc: connection.send_result(msg["id"], exc.validation_error) return try: await knx.config_store.update_expose( validated_data["entity_id"], validated_data["data"] ) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result( msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) ) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/delete_expose", vol.Required("entity_id"): str, } ) @websocket_api.async_response @provide_knx async def ws_delete_expose( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Delete expose configuration from config store.""" try: await knx.config_store.delete_expose(msg["entity_id"]) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result(msg["id"]) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/validate_expose", vol.Required("entity_id"): str, vol.Required("data"): dict, # validation done in handler } ) @callback def ws_validate_expose( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Validate expose data.""" try: validate_expose_data(msg) except EntityStoreValidationException as exc: connection.send_result(msg["id"], exc.validation_error) return connection.send_result( msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) ) ############# # Time server ############# @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/get_time_server_config", } ) @provide_knx @callback def ws_get_time_server_config( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Get time server configuration from entity store.""" config_info = knx.config_store.get_time_server_config() connection.send_result(msg["id"], config_info) @websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "knx/update_time_server_config", vol.Required("config"): dict, # validation done in handler } ) @websocket_api.async_response @provide_knx async def ws_update_time_server_config( hass: HomeAssistant, knx: KNXModule, connection: websocket_api.ActiveConnection, msg: dict, ) -> None: """Update entity in entity store and reload it.""" try: validated_config = validate_time_server_data(msg["config"]) except EntityStoreValidationException as exc: connection.send_result(msg["id"], exc.validation_error) return try: await knx.config_store.update_time_server_config(validated_config) except ConfigStoreException as err: connection.send_error( msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) ) return connection.send_result( msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None) )