diff --git a/.github/workflows/python-code-format.yml b/.github/workflows/python-code-format.yml index c71e7ad..da13f31 100644 --- a/.github/workflows/python-code-format.yml +++ b/.github/workflows/python-code-format.yml @@ -46,4 +46,4 @@ jobs: python -m isort ucapi/. --check --verbose - name: Check code formatting with black run: | - python -m black ucapi --check --verbose --line-length 120 + python -m black ucapi --check --verbose diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..df77493 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/integration-python-library.iml b/.idea/integration-python-library.iml new file mode 100644 index 0000000..bbb1500 --- /dev/null +++ b/.idea/integration-python-library.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1e35929 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..cc371ca --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check_Black_style.xml b/.idea/runConfigurations/Check_Black_style.xml new file mode 100644 index 0000000..68437b5 --- /dev/null +++ b/.idea/runConfigurations/Check_Black_style.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check_flake8.xml b/.idea/runConfigurations/Check_flake8.xml new file mode 100644 index 0000000..5a19db2 --- /dev/null +++ b/.idea/runConfigurations/Check_flake8.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check_isort.xml b/.idea/runConfigurations/Check_isort.xml new file mode 100644 index 0000000..344b25c --- /dev/null +++ b/.idea/runConfigurations/Check_isort.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check_pylint.xml b/.idea/runConfigurations/Check_pylint.xml new file mode 100644 index 0000000..852aeaa --- /dev/null +++ b/.idea/runConfigurations/Check_pylint.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Run_isort.xml b/.idea/runConfigurations/Run_isort.xml new file mode 100644 index 0000000..e44bb7f --- /dev/null +++ b/.idea/runConfigurations/Run_isort.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 8a04d39..612b221 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # Python API wrapper for the UC Integration API +[![PyPi](https://img.shields.io/pypi/v/ucapi.svg)](https://pypi.org/project/ucapi) +[![License](https://img.shields.io/github/license/unfoldedcircle/ucapi.svg)](LICENSE) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) This library simplifies writing Python based integrations for the [Unfolded Circle Remote Two](https://www.unfoldedcircle.com/) by wrapping the [WebSocket Integration API](https://github.com/unfoldedcircle/core-api/tree/main/integration-api). @@ -19,6 +22,12 @@ Not yet supported: Requirements: - Python 3.10 or newer +## Installation + +Use pip: +```shell +pip3 install ucapi +``` ## Usage Install build tools: diff --git a/docs/code_guidelines.md b/docs/code_guidelines.md index abbea37..8470923 100644 --- a/docs/code_guidelines.md +++ b/docs/code_guidelines.md @@ -20,7 +20,7 @@ Note: once is implemented, the requir ### Linting ```shell -python -m pylint ucapi +python3 -m pylint ucapi ``` - The tool is configured in `.pylintrc`. @@ -34,7 +34,7 @@ Linting integration in PyCharm/IntelliJ IDEA: Import statements must be sorted with [isort](https://pycqa.github.io/isort/): ```shell -python -m isort ucapi/. +python3 -m isort ucapi/. ``` - The tool is configured in `pyproject.toml` to use the `black` code-formatting profile. @@ -44,7 +44,7 @@ python -m isort ucapi/. Source code is formatted with the [Black code formatting tool](https://github.com/psf/black): ```shell -python -m black ucapi --line-length 120 +python3 -m black ucapi --line-length 120 ``` PyCharm/IntelliJ IDEA integration: @@ -52,15 +52,14 @@ PyCharm/IntelliJ IDEA integration: 2. Configure: - Python interpreter - Use Black formatter: `On code reformat` & optionally `On save` -- Arguments: `--line-length 120` ## Verify The following tests are run as GitHub action for each push on the main branch and for pull requests. They can also be run anytime on a local developer machine: ```shell -python -m pylint ucapi -python -m flake8 ucapi --count --show-source --statistics -python -m isort ucapi/. --check --verbose -python -m black ucapi --check --verbose --line-length 120 +python3 -m pylint ucapi +python3 -m flake8 ucapi --count --show-source --statistics +python3 -m isort ucapi/. --check --verbose +python3 -m black ucapi --check --verbose ``` diff --git a/examples/hello_integration.py b/examples/hello_integration.py index 6c0ffa6..15c5722 100644 --- a/examples/hello_integration.py +++ b/examples/hello_integration.py @@ -10,7 +10,9 @@ api = ucapi.IntegrationAPI(loop) -async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] | None) -> ucapi.StatusCodes: +async def cmd_handler( + entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] | None +) -> ucapi.StatusCodes: """ Push button command handler. @@ -28,7 +30,7 @@ async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] @api.listens_to(ucapi.Events.CONNECT) async def on_connect() -> None: - """When the remote connects, we just set the device state. We are ready all the time!""" + # When the remote connects, we just set the device state. We are ready all the time! await api.set_device_state(ucapi.DeviceStates.CONNECTED) diff --git a/examples/setup_flow.py b/examples/setup_flow.py index d9fde2b..eefc522 100644 --- a/examples/setup_flow.py +++ b/examples/setup_flow.py @@ -16,7 +16,8 @@ async def driver_setup_handler(msg: ucapi.SetupDriver) -> ucapi.SetupAction: Either start the setup process or handle the provided user input data. - :param msg: the setup driver request object, either DriverSetupRequest or UserDataResponse + :param msg: the setup driver request object, either DriverSetupRequest, + UserDataResponse or UserConfirmationResponse :return: the setup action on how to continue """ if isinstance(msg, ucapi.DriverSetupRequest): @@ -31,16 +32,19 @@ async def driver_setup_handler(msg: ucapi.SetupDriver) -> ucapi.SetupAction: return ucapi.SetupError() -async def handle_driver_setup(msg: ucapi.DriverSetupRequest) -> ucapi.RequestUserInput | ucapi.SetupError: +async def handle_driver_setup( + msg: ucapi.DriverSetupRequest, +) -> ucapi.SetupAction: """ Start driver setup. Initiated by Remote Two to set up the driver. - :param msg: not used, value(s) of input fields in the first setup screen. See setup_data_schema in driver metadata. + :param msg: value(s) of input fields in the first setup screen. :return: the setup action on how to continue """ - # for our demo we clear everything, a real driver might have to handle this differently + # For our demo we simply clear everything! + # A real driver might have to handle this differently api.available_entities.clear() api.configured_entities.clear() @@ -141,7 +145,9 @@ async def handle_user_data_response(msg: ucapi.UserDataResponse) -> ucapi.SetupA return ucapi.SetupError() -async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] | None) -> ucapi.StatusCodes: +async def cmd_handler( + entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] | None +) -> ucapi.StatusCodes: """ Push button command handler. @@ -159,7 +165,7 @@ async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] @api.listens_to(ucapi.Events.CONNECT) async def on_connect() -> None: - """When the remote connects, we just set the device state. We are ready all the time!""" + # When the remote connects, we just set the device state. We are ready all the time! await api.set_device_state(ucapi.DeviceStates.CONNECTED) diff --git a/pyproject.toml b/pyproject.toml index 328d464..5b245d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ profile = "black" overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"] [tool.pylint.format] -max-line-length = "120" +max-line-length = "88" [tool.pylint.MASTER] ignore-paths = [ @@ -73,16 +73,20 @@ ignore-paths = [ [tool.pylint."messages control"] # Reasons disabled: +# duplicate-code - unavoidable # global-statement - not yet considered # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* +# line-too-long - handled with black & flake8 # fixme - refactoring in progress disable = [ + "duplicate-code", "global-statement", "too-many-arguments", "too-many-instance-attributes", "too-few-public-methods", + "line-too-long", "fixme" ] diff --git a/setup.cfg b/setup.cfg index 6deafc2..2bcd70e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [flake8] -max-line-length = 120 +max-line-length = 88 diff --git a/ucapi/__init__.py b/ucapi/__init__.py index 85e6158..1a25e0f 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ Integration driver library for Remote Two. diff --git a/ucapi/api.py b/ucapi/api.py index 666edf7..1565bf3 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ Integration driver API for Remote Two. @@ -17,10 +16,10 @@ import websockets from pyee.asyncio import AsyncIOEventEmitter -# workaround for pylint error: E0611: No name 'ConnectionClosedOK' in module 'websockets' (no-name-in-module) +# workaround for pylint error: E0611: No name 'ConnectionClosedOK' in module 'websockets' (no-name-in-module) # noqa from websockets.exceptions import ConnectionClosedOK -# workaround for pylint error: E1101: Module 'websockets' has no 'serve' member (no-member) +# workaround for pylint error: E1101: Module 'websockets' has no 'serve' member (no-member) # noqa from websockets.server import serve from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -50,7 +49,9 @@ def __init__(self, loop: AbstractEventLoop): self._server_task = None self._clients = set() - self._config_dir_path: str = os.getenv("UC_CONFIG_HOME") or os.getenv("HOME") or "./" + self._config_dir_path: str = ( + os.getenv("UC_CONFIG_HOME") or os.getenv("HOME") or "./" + ) self._available_entities = Entities("available", self._loop) self._configured_entities = Entities("configured", self._loop) @@ -58,17 +59,22 @@ def __init__(self, loop: AbstractEventLoop): # Setup event loop asyncio.set_event_loop(self._loop) - async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = None): + 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 + :param setup_handler: optional driver setup handler if the driver metadata + contains a setup_data_schema object """ self._driver_path = driver_path self._setup_handler = setup_handler - self._configured_entities.add_listener(uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated) + self._configured_entities.add_listener( + uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated + ) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: @@ -77,18 +83,24 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N # publishing interface, defaults to "0.0.0.0" if not set interface = os.getenv("UC_INTEGRATION_INTERFACE") port = int( - os.getenv("UC_INTEGRATION_HTTP_PORT") or self._driver_info["port"] if "port" in self._driver_info else 9090 + os.getenv("UC_INTEGRATION_HTTP_PORT") or self._driver_info["port"] + if "port" in self._driver_info + else 9090 ) _adjust_driver_url(self._driver_info, port) - disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") + disable_mdns_publish = os.getenv( + "UC_DISABLE_MDNS_PUBLISH", "false" + ).lower() in ("true", "1") if disable_mdns_publish is False: # Setup zeroconf service info name = f"{self._driver_info['driver_id']}._uc-integration._tcp.local." hostname = local_hostname() - driver_name = _get_default_language_string(self._driver_info["name"], "Unknown driver") + driver_name = _get_default_language_string( + self._driver_info["name"], "Unknown driver" + ) _LOG.debug("Publishing driver: name=%s, host=%s:%d", name, hostname, port) @@ -108,7 +120,9 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N await zeroconf.async_register_service(info) host = interface if interface is not None else "0.0.0.0" - self._server_task = self._loop.create_task(self._start_web_socket_server(host, port)) + self._server_task = self._loop.create_task( + self._start_web_socket_server(host, port) + ) _LOG.info( "Driver is up: %s, version: %s, listening on: %s:%d", @@ -125,7 +139,9 @@ async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes "attributes": attributes, } - await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) + await self._broadcast_ws_event( + uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY + ) async def _start_web_socket_server(self, host: str, port: int) -> None: async with serve(self._handle_ws, host, port): @@ -157,7 +173,9 @@ async def _handle_ws(self, websocket) -> None: _LOG.info("WS: Client removed") self._events.emit(uc.Events.DISCONNECT) - async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None) -> None: + async def _send_ok_result( + self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None + ) -> None: """ Send a WebSocket success message with status code OK. @@ -168,7 +186,9 @@ async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] Raises: websockets.ConnectionClosed: When the connection is closed. """ - await self._send_ws_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) + await self._send_ws_response( + websocket, req_id, "result", msg_data, uc.StatusCodes.OK + ) async def _send_error_result( self, @@ -225,11 +245,14 @@ async def _send_ws_response( else: _LOG.error("Error sending response: connection no longer established") - async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: + async def _broadcast_ws_event( + self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory + ) -> None: """ Send the given event-message to all connected WebSocket clients. - If a client is no longer connected, a log message is printed and the remaining clients are notified. + If a client is no longer connected, a log message is printed and the remaining + clients are notified. :param msg: event message name :param msg_data: message data payload @@ -245,7 +268,9 @@ async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category except websockets.exceptions.WebSocketException: pass - async def _send_ws_event(self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: + async def _send_ws_event( + self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory + ) -> None: """ Send an event-message to the given WebSocket client. @@ -277,17 +302,31 @@ async def _process_ws_message(self, websocket, message) -> None: if kind == "req": if req_id is None: - _LOG.warning("Ignoring request message with missing 'req_id': %s", message) + _LOG.warning( + "Ignoring request message with missing 'req_id': %s", message + ) else: await self._handle_ws_request_msg(websocket, msg, req_id, msg_data) elif kind == "event": self._handle_ws_event_msg(msg, msg_data) - async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _handle_ws_request_msg( + self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if msg == uc.WsMessages.GET_DRIVER_VERSION: - await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_VERSION, self.get_driver_version()) + await self._send_ws_response( + websocket, + req_id, + uc.WsMsgEvents.DRIVER_VERSION, + self.get_driver_version(), + ) elif msg == uc.WsMessages.GET_DEVICE_STATE: - await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DEVICE_STATE, {"state": self.device_state}) + await self._send_ws_response( + websocket, + req_id, + uc.WsMsgEvents.DEVICE_STATE, + {"state": self.device_state}, + ) elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: available_entities = self._available_entities.get_all() await self._send_ws_response( @@ -313,7 +352,9 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat await self._unsubscribe_events(msg_data) await self._send_ok_result(websocket, req_id) elif msg == uc.WsMessages.GET_DRIVER_METADATA: - await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info) + await self._send_ws_response( + websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info + ) elif msg == uc.WsMessages.SETUP_DRIVER: if not await self._setup_driver(websocket, req_id, msg_data): await self.driver_setup_error(websocket) @@ -358,7 +399,9 @@ async def set_device_state(self, state: uc.DeviceStates) -> None: self._state = state await self._broadcast_ws_event( - uc.WsMsgEvents.DEVICE_STATE, {"state": self.device_state}, uc.EventCategory.DEVICE + uc.WsMsgEvents.DEVICE_STATE, + {"state": self.device_state}, + uc.EventCategory.DEVICE, ) async def _subscribe_events(self, msg_data: dict[str, Any] | None) -> None: @@ -392,49 +435,74 @@ async def _unsubscribe_events(self, msg_data: dict[str, Any] | None) -> bool: return res - async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _entity_command( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring _entity_command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None cmd_id = msg_data["cmd_id"] if "cmd_id" in msg_data else None if entity_id is None or cmd_id is None: _LOG.warning("Ignoring command: missing entity_id or cmd_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) if entity is None: - _LOG.warning("Cannot execute command '%s' for '%s': no configured entity found", cmd_id, entity_id) + _LOG.warning( + "Cannot execute command '%s' for '%s': no configured entity found", + cmd_id, + entity_id, + ) await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) return - result = await entity.command(cmd_id, msg_data["params"] if "params" in msg_data else None) + 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) -> bool: + 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) + # 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) + _LOG.error( + "Received setup_driver request, but no setup handler provided by the driver!" # noqa + ) + 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"])) + 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) + 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) @@ -447,34 +515,50 @@ async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | return True # error action is left, handled below - except Exception as ex: # pylint: disable=W0718 # TODO define custom exceptions? + # TODO define custom exceptions? + except Exception as ex: # pylint: disable=W0718 _LOG.error("Exception in setup handler, aborting setup! Exception: %s", ex) return False - async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + 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) + # 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: %s", msg_data) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + _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"])) + 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"])) + 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) + 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( @@ -486,7 +570,9 @@ async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str return True # error action is left, handled below - except Exception as ex: # pylint: disable=W0718 # TODO define custom exceptions? + except ( + Exception # pylint: disable=W0718 # TODO define custom exceptions? + ) as ex: _LOG.error("Exception in setup handler, aborting setup! Exception: %s", ex) return False @@ -517,7 +603,9 @@ async def driver_setup_progress(self, websocket) -> None: """ data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def request_driver_setup_user_confirmation( self, @@ -552,7 +640,9 @@ async def request_driver_setup_user_confirmation( }, } - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def request_driver_setup_user_input( self, websocket, title: str | dict[str, str], settings: dict[str, Any] | list @@ -561,22 +651,30 @@ async def request_driver_setup_user_input( data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", - "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, + "require_user_action": { + "input": {"title": _to_language_object(title), "settings": settings} + }, } - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def driver_setup_complete(self, websocket) -> None: """Send a driver setup complete event to Remote Two.""" data = {"event_type": "STOP", "state": "OK"} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def driver_setup_error(self, websocket, error="OTHER") -> None: """Send a driver setup error event to Remote Two.""" data = {"event_type": "STOP", "state": "ERROR", "error": error} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) def add_listener(self, event: uc.Events, f: Callable) -> None: """ @@ -587,8 +685,13 @@ def add_listener(self, event: uc.Events, f: Callable) -> None: """ self._events.add_listener(event, f) - def listens_to(self, event: str) -> Callable[[Callable], Callable]: - """Return a decorator which will register the decorated function to the specified event.""" + def listens_to(self, event: uc.Events) -> Callable[[Callable], Callable]: + """ + Register the given event. + + :return: a decorator which will register the decorated function to the specified + event. + """ def on(f: Callable) -> Callable: self._events.add_listener(event, f) @@ -653,7 +756,9 @@ def _to_language_object(text: str | dict[str, str] | None) -> dict[str, str] | N return text -def _get_default_language_string(text: str | dict[str, str] | None, default_text="Undefined") -> str: +def _get_default_language_string( + text: str | dict[str, str] | None, default_text="Undefined" +) -> str: if text is None: return default_text @@ -709,7 +814,11 @@ def local_hostname() -> str: :return: the local hostname """ # Override option for announced hostname. - # Useful on macOS where it's broken for several years: local hostname keeps on changing! + # Useful on macOS where it's broken for several years: + # local hostname keeps on changing with a increasing number suffix! # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end - return os.getenv("UC_MDNS_LOCAL_HOSTNAME") or f"{socket.gethostname().split('.', 1)[0]}.local." + return ( + os.getenv("UC_MDNS_LOCAL_HOSTNAME") + or f"{socket.gethostname().split('.', 1)[0]}.local." + ) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 3cc9b0c..db42f0e 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -108,13 +108,15 @@ 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. + 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] @@ -123,10 +125,10 @@ class DriverSetupRequest(SetupDriver): @dataclass class UserDataResponse(SetupDriver): """ - Provide requested driver setup data to the integration driver during a setup process. + Provide requested driver setup data to the integration driver in a setup process. - The ``input_values`` dictionary contains the user input data. The key is the input field identifier, - value contains the input value. + 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] @@ -134,7 +136,13 @@ class UserDataResponse(SetupDriver): @dataclass class UserConfirmationResponse(SetupDriver): - """Provide user confirmation response to the integration driver during a setup process.""" + """ + Provide user confirmation response to the integration driver in a setup process. + + The ``confirm`` field is set to ``true`` if the user had to perform an action like + pressing a button on a device and then confirms the action with continuing the + setup process. + """ confirm: bool @@ -172,7 +180,9 @@ class SetupComplete(SetupAction): """Setup action to complete a successful setup process.""" -CommandHandler: TypeAlias = Callable[[Any, str, dict[str, Any] | None], Awaitable[StatusCodes]] +CommandHandler: TypeAlias = Callable[ + [Any, str, dict[str, Any] | None], Awaitable[StatusCodes] +] SetupHandler: TypeAlias = Callable[[SetupDriver], Awaitable[SetupAction]] diff --git a/ucapi/button.py b/ucapi/button.py index 6289877..f78d8ad 100644 --- a/ucapi/button.py +++ b/ucapi/button.py @@ -36,7 +36,7 @@ class Button(Entity): See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_button.md for more information. - """ + """ # noqa def __init__( self, diff --git a/ucapi/climate.py b/ucapi/climate.py index 6574881..2a56e32 100644 --- a/ucapi/climate.py +++ b/ucapi/climate.py @@ -1,4 +1,3 @@ -# pylint: disable=R0801 """ Climate entity definitions. @@ -80,7 +79,7 @@ class Climate(Entity): See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_climate.md for more information. - """ + """ # noqa def __init__( self, @@ -106,5 +105,13 @@ def __init__( :param cmd_handler: handler for entity commands """ super().__init__( - identifier, name, EntityTypes.CLIMATE, features, attributes, device_class, options, area, cmd_handler + identifier, + name, + EntityTypes.CLIMATE, + features, + attributes, + device_class, + options, + area, + cmd_handler, ) diff --git a/ucapi/cover.py b/ucapi/cover.py index b41578a..145a941 100644 --- a/ucapi/cover.py +++ b/ucapi/cover.py @@ -1,4 +1,3 @@ -# pylint: disable=R0801 """ Cover entity definitions. @@ -79,7 +78,7 @@ class Cover(Entity): See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_cover.md for more information. - """ + """ # noqa def __init__( self, diff --git a/ucapi/entities.py b/ucapi/entities.py index 96eca9c..efa1ebb 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -67,7 +67,11 @@ def remove(self, entity_id: str) -> bool: def update_attributes(self, entity_id: str, attributes: dict[str, Any]) -> bool: """Update entity attributes.""" if entity_id not in self._storage: - _LOG.debug("[%s] cannot update entity attributes '%s': not found", self._id, entity_id) + _LOG.debug( + "[%s] cannot update entity attributes '%s': not found", + self._id, + entity_id, + ) return False for key in attributes: @@ -107,7 +111,11 @@ def get_all(self) -> list[dict[str, Any]]: return entities async def get_states(self) -> list[dict[str, Any]]: - """Get all entity information with entity_id, entity_type, device_id, attributes.""" + """ + Get all entity state information. + + The returned dict includes: entity_id, entity_type, device_id, attributes. + """ entities = [] for entity in self._storage.values(): diff --git a/ucapi/entity.py b/ucapi/entity.py index fc5e335..67c9976 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -72,7 +72,9 @@ def __init__( _LOG.debug("Created %s entity: %s", self.entity_type.value, self.id) - async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> StatusCodes: + async def command( + self, cmd_id: str, params: dict[str, Any] | None = None + ) -> StatusCodes: """ Execute entity command with the installed command handler. @@ -86,6 +88,9 @@ async def command(self, cmd_id: str, params: dict[str, Any] | None = None) -> St 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 "" + "No command handler for %s: cannot execute command '%s' %s", + self.id, + cmd_id, + params if params else "", ) return StatusCodes.NOT_IMPLEMENTED diff --git a/ucapi/light.py b/ucapi/light.py index b7f56c1..f7f22f6 100644 --- a/ucapi/light.py +++ b/ucapi/light.py @@ -1,4 +1,3 @@ -# pylint: disable=R0801 """ Light entity definitions. @@ -66,7 +65,7 @@ class Light(Entity): See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_light.md for more information. - """ + """ # noqa def __init__( self, @@ -92,5 +91,13 @@ def __init__( :param cmd_handler: handler for entity commands """ super().__init__( - identifier, name, EntityTypes.LIGHT, features, attributes, device_class, options, area, cmd_handler + identifier, + name, + EntityTypes.LIGHT, + features, + attributes, + device_class, + options, + area, + cmd_handler, ) diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 06415e7..36b335b 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -1,4 +1,3 @@ -# pylint: disable=R0801 """ Media-player entity definitions. @@ -154,7 +153,7 @@ class MediaPlayer(Entity): See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_media_player.md for more information. - """ + """ # noqa def __init__( self, @@ -180,5 +179,13 @@ def __init__( :param cmd_handler: handler for entity commands """ super().__init__( - identifier, name, EntityTypes.MEDIA_PLAYER, features, attributes, device_class, options, area, cmd_handler + identifier, + name, + EntityTypes.MEDIA_PLAYER, + features, + attributes, + device_class, + options, + area, + cmd_handler, ) diff --git a/ucapi/sensor.py b/ucapi/sensor.py index c098175..6da7fb3 100644 --- a/ucapi/sensor.py +++ b/ucapi/sensor.py @@ -1,4 +1,3 @@ -# pylint: disable=R0801 """ Sensor entity definitions. @@ -65,7 +64,7 @@ class Sensor(Entity): See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_sensor.md for more information. - """ + """ # noqa def __init__( self, diff --git a/ucapi/switch.py b/ucapi/switch.py index 8574767..70c26f2 100644 --- a/ucapi/switch.py +++ b/ucapi/switch.py @@ -61,7 +61,7 @@ class Switch(Entity): See https://github.com/unfoldedcircle/core-api/blob/main/doc/entities/entity_switch.md for more information. - """ + """ # noqa def __init__( self,