Skip to content

Commit

Permalink
refactor: driver setup process
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
zehnm committed Oct 29, 2023
1 parent 5436d35 commit 48947e5
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 23 deletions.
2 changes: 1 addition & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 15 additions & 1 deletion ucapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 82 additions & 14 deletions ucapi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
92 changes: 88 additions & 4 deletions ucapi/api_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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"
Expand All @@ -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]]
6 changes: 3 additions & 3 deletions ucapi/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 ""
Expand Down

0 comments on commit 48947e5

Please sign in to comment.