From 48947e5c5a60fd9e0b908a25c1feaabd0355bd74 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sun, 29 Oct 2023 18:12:16 +0100 Subject: [PATCH] refactor: driver setup process Don't expose websocket or request ids. Setup flow is controlled by new data classes: - Return object is the next step in the setup flow. - The client should not have to know or call driver_setup_error, request_driver_setup_user_input, etc. --- .pylintrc | 2 +- ucapi/__init__.py | 16 ++++++- ucapi/api.py | 96 ++++++++++++++++++++++++++++++++++------ ucapi/api_definitions.py | 92 ++++++++++++++++++++++++++++++++++++-- ucapi/entity.py | 6 +-- 5 files changed, 189 insertions(+), 23 deletions(-) diff --git a/.pylintrc b/.pylintrc index 1facecf..7404d48 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,6 +1,6 @@ [MAIN] # Specify a score threshold to be exceeded before program exits with error. -fail-under=9.5 +fail-under=9.8 # Return non-zero exit code if any of these messages/categories are detected, # even if score is above --fail-under value. Syntax same as enable. Messages diff --git a/ucapi/__init__.py b/ucapi/__init__.py index b4c0d16..1742853 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -10,7 +10,21 @@ # Set default logging handler to avoid "No handler found" warnings. import logging # isort:skip -from .api_definitions import DeviceStates, Events, StatusCodes # isort:skip # noqa: F401 +from .api_definitions import ( # isort:skip # noqa: F401 + DeviceStates, + DriverSetupRequest, + Events, + IntegrationSetupError, + RequestUserConfirmation, + RequestUserInput, + SetupAction, + SetupComplete, + SetupDriver, + SetupError, + StatusCodes, + UserConfirmationResponse, + UserDataResponse, +) from .entity import Entity, EntityTypes # isort:skip # noqa: F401 from .entities import Entities # isort:skip # noqa: F401 from .api import IntegrationAPI # isort:skip # noqa: F401 diff --git a/ucapi/api.py b/ucapi/api.py index c8ea2fd..55eed66 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -42,6 +42,7 @@ def __init__(self, loop: AbstractEventLoop): """ self._loop = loop self._events = AsyncIOEventEmitter(self._loop) + self._setup_handler: uc.SetupHandler | None = None self._driver_info: dict[str, Any] = {} self._driver_path: str | None = None self._state: uc.DeviceStates = uc.DeviceStates.DISCONNECTED @@ -61,14 +62,16 @@ def __init__(self, loop: AbstractEventLoop): # Setup event loop asyncio.set_event_loop(self._loop) - async def init(self, driver_path: str): + async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = None): """ Load driver configuration and start integration-API WebSocket server. :param driver_path: path to configuration file + :param setup_handler: optional driver setup handler if the driver metadata contains a setup_data_schema object """ self._driver_path = driver_path self._port = self._driver_info["port"] if "port" in self._driver_info else self._port + self._setup_handler = setup_handler self._configured_entities.add_listener(uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated) @@ -326,9 +329,11 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat elif msg == uc.WsMessages.GET_DRIVER_METADATA: await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info) elif msg == uc.WsMessages.SETUP_DRIVER: - await self._setup_driver(websocket, req_id, msg_data) + if not await self._setup_driver(websocket, req_id, msg_data): + await self.driver_setup_error(websocket) elif msg == uc.WsMessages.SET_DRIVER_USER_DATA: - await self._set_driver_user_data(websocket, req_id, msg_data) + if not await self._set_driver_user_data(websocket, req_id, msg_data): + await self.driver_setup_error(websocket) def _handle_ws_event_msg(self, msg: str, _msg_data: dict[str, Any] | None) -> None: if msg == uc.WsMsgEvents.CONNECT: @@ -423,19 +428,82 @@ async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] result = await entity.command(cmd_id, msg_data["params"] if "params" in msg_data else None) await self.acknowledge_command(websocket, req_id, result) - async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: - if msg_data is None: - _LOG.warning("Ignoring _setup_driver: called with empty msg_data") - return - self._events.emit(uc.Events.SETUP_DRIVER, websocket, req_id, msg_data["setup_data"]) + async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + if msg_data is None or "setup_data" not in msg_data: + _LOG.warning("Aborting setup_driver: called with empty msg_data") + # TODO test if both messages are required, or if we first have to ack with OK, then abort the setup + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + return False + + # make sure integration driver installed a setup handler + if not self._setup_handler: + _LOG.error("Received setup_driver request, but no setup handler provided by the driver!") + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVICE_UNAVAILABLE) + return False + + await self.acknowledge_command(websocket, req_id) + + try: + action = await self._setup_handler(uc.DriverSetupRequest(msg_data["setup_data"])) + + if isinstance(action, uc.RequestUserInput): + await self.driver_setup_progress(websocket) + await self.request_driver_setup_user_input(websocket, action.title, action.settings) + return True + if isinstance(action, uc.RequestUserConfirmation): + await self.driver_setup_progress(websocket) + await self.request_driver_setup_user_confirmation( + websocket, action.title, action.header, action.image, action.footer + ) + return True + if isinstance(action, uc.SetupComplete): + await self.driver_setup_complete(websocket) + return True + + # error action is left, handled below + except Exception as ex: # TODO define custom exceptions? + _LOG.error("Exception in setup handler, aborting setup! Exception: %s", ex) - async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: - if "input_values" in msg_data: - self._events.emit(uc.Events.SETUP_DRIVER_USER_DATA, websocket, req_id, msg_data["input_values"]) - elif "confirm" in msg_data: - self._events.emit(uc.Events.SETUP_DRIVER_USER_CONFIRMATION, websocket, req_id, data=None) + return False + + async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + if not self._setup_handler: + # TODO test if both messages are required, or if we first have to ack with OK, then abort the setup + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVICE_UNAVAILABLE) + return False + + if "input_values" in msg_data or "confirm" in msg_data: + await self.acknowledge_command(websocket, req_id) + await self.driver_setup_progress(websocket) else: - _LOG.warning("Unsupported set_driver_user_data payload received") + _LOG.warning("Unsupported set_driver_user_data payload received: %s", msg_data) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + return False + + try: + action = uc.SetupError() + if "input_values" in msg_data: + action = await self._setup_handler(uc.UserDataResponse(msg_data["input_values"])) + elif "confirm" in msg_data: + action = await self._setup_handler(uc.UserConfirmationResponse(msg_data["confirm"])) + + if isinstance(action, uc.RequestUserInput): + await self.request_driver_setup_user_input(websocket, action.title, action.settings) + return True + if isinstance(action, uc.RequestUserConfirmation): + await self.request_driver_setup_user_confirmation( + websocket, action.title, action.header, action.image, action.footer + ) + return True + if isinstance(action, uc.SetupComplete): + await self.driver_setup_complete(websocket) + return True + + # error action is left, handled below + except Exception as ex: # TODO define custom exceptions? + _LOG.error("Exception in setup handler, aborting setup! Exception: %s", ex) + + return False async def acknowledge_command( self, websocket, req_id: int, status_code: uc.StatusCodes = uc.StatusCodes.OK diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 70b9f7e..3cc9b0c 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -4,7 +4,7 @@ :copyright: (c) 2023 by Unfolded Circle ApS. :license: MPL-2.0, see LICENSE for more details. """ - +from dataclasses import dataclass from enum import Enum, IntEnum from typing import Any, Awaitable, Callable, TypeAlias @@ -32,6 +32,17 @@ class StatusCodes(IntEnum): SERVICE_UNAVAILABLE = 503 +class IntegrationSetupError(str, Enum): + """More detailed error reason for ``state: ERROR`` condition.""" + + NONE = "NONE" + NOT_FOUND = "NOT_FOUND" + CONNECTION_REFUSED = "CONNECTION_REFUSED" + AUTHORIZATION_ERROR = "AUTHORIZATION_ERROR" + TIMEOUT = "TIMEOUT" + OTHER = "OTHER" + + # Does WsMessages need to be public? class WsMessages(str, Enum): """WebSocket request messages from Remote Two.""" @@ -73,9 +84,6 @@ class Events(str, Enum): ENTITY_ATTRIBUTES_UPDATED = "entity_attributes_updated" SUBSCRIBE_ENTITIES = "subscribe_entities" UNSUBSCRIBE_ENTITIES = "unsubscribe_entities" - SETUP_DRIVER = "setup_driver" - SETUP_DRIVER_USER_DATA = "setup_driver_user_data" - SETUP_DRIVER_USER_CONFIRMATION = "setup_driver_user_confirmation" SETUP_DRIVER_ABORT = "setup_driver_abort" CONNECT = "connect" DISCONNECT = "disconnect" @@ -91,4 +99,80 @@ class EventCategory(str, Enum): ENTITY = "ENTITY" +class SetupDriver: + """Driver setup request base class.""" + + +@dataclass +class DriverSetupRequest(SetupDriver): + """ + Start driver setup. + + If a driver includes a ``setup_data_schema`` object in its driver metadata, it enables the dynamic driver setup + process. The setup process can be a simple "start-confirm-done" between the Remote Two and the integration + driver, or a fully dynamic, multistep process with user interactions, where the user has to provide additional + data or select different options. + + If the initial setup page contains input fields and not just text, the input values are returned in the + ``setup_data`` dictionary. The key is the input field identifier, value contains the input value. + """ + + setup_data: dict[str, str] + + +@dataclass +class UserDataResponse(SetupDriver): + """ + Provide requested driver setup data to the integration driver during a setup process. + + The ``input_values`` dictionary contains the user input data. The key is the input field identifier, + value contains the input value. + """ + + input_values: dict[str, str] + + +@dataclass +class UserConfirmationResponse(SetupDriver): + """Provide user confirmation response to the integration driver during a setup process.""" + + confirm: bool + + +class SetupAction: + """Setup action response base class.""" + + +@dataclass +class RequestUserInput(SetupAction): + """Setup action to request user input.""" + + title: str | dict[str, str] + settings: list[dict[str, Any]] + + +@dataclass +class RequestUserConfirmation(SetupAction): + """Setup action to request a user confirmation.""" + + title: str | dict[str, str] + header: str | dict[str, str] | None = None + image: str | None = None + footer: str | dict[str, str] | None = None + + +@dataclass +class SetupError(SetupAction): + """Setup action to abort setup process due to an error.""" + + error_type: IntegrationSetupError = IntegrationSetupError.OTHER + + +class SetupComplete(SetupAction): + """Setup action to complete a successful setup process.""" + + CommandHandler: TypeAlias = Callable[[Any, str, dict[str, Any] | None], Awaitable[StatusCodes]] + + +SetupHandler: TypeAlias = Callable[[SetupDriver], Awaitable[SetupAction]] diff --git a/ucapi/entity.py b/ucapi/entity.py index eb45292..fc5e335 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -68,7 +68,7 @@ def __init__( self.device_class = device_class self.options = options self.area = area - self.cmd_handler = cmd_handler + self._cmd_handler = cmd_handler _LOG.debug("Created %s entity: %s", self.entity_type.value, self.id) @@ -82,8 +82,8 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St :param params: optional command parameters :return: command status code to acknowledge to UCR2 """ - if self.cmd_handler: - return await self.cmd_handler(self, cmd_id, params) + if self._cmd_handler: + return await self._cmd_handler(self, cmd_id, params) _LOG.warning( "No command handler for %s: cannot execute command '%s' %s", self.id, cmd_id, params if params else ""