From cbc0bf2abf7b07b31d80ac89b548f88c62507c46 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Thu, 26 Oct 2023 23:58:46 +0200 Subject: [PATCH 01/25] Fix linting, refactor invalid names --- ucapi/__init__.py | 2 +- ucapi/api.py | 276 ++++++++++++++++++++++----------------- ucapi/api_definitions.py | 20 +-- ucapi/button.py | 14 +- ucapi/climate.py | 26 ++-- ucapi/cover.py | 26 ++-- ucapi/entities.py | 33 +++-- ucapi/entity.py | 12 +- ucapi/light.py | 26 ++-- ucapi/media_player.py | 28 ++-- ucapi/sensor.py | 26 ++-- ucapi/switch.py | 26 ++-- 12 files changed, 274 insertions(+), 241 deletions(-) diff --git a/ucapi/__init__.py b/ucapi/__init__.py index 92dc6f7..32b2270 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -4,5 +4,5 @@ Integration driver library for Remote Two. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ diff --git a/ucapi/api.py b/ucapi/api.py index d3708ee..9541c01 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -2,7 +2,7 @@ Integration driver API for Remote Two. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import asyncio @@ -11,6 +11,7 @@ import os import socket from asyncio import AbstractEventLoop +from typing import Any import websockets from pyee import AsyncIOEventEmitter @@ -36,36 +37,36 @@ def __init__(self, loop: AbstractEventLoop): """ self._loop = loop self.events = AsyncIOEventEmitter(self._loop) - self.driverInfo = {} - self._driver_path = None - self.state = uc.DEVICE_STATES.DISCONNECTED + self.driver_info = {} + self._driver_path: str | None = None + self.state: uc.DeviceStates = uc.DeviceStates.DISCONNECTED self._server_task = None self._clients = set() self._interface = os.getenv("UC_INTEGRATION_INTERFACE") self._port = os.getenv("UC_INTEGRATION_HTTP_PORT") # TODO: add support for secured - self._https_enabled = os.getenv("UC_INTEGRATION_HTTPS_ENABLED", "False").lower() in ("true", "1", "t") - self._disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "False").lower() in ("true", "1", "t") + self._https_enabled = os.getenv("UC_INTEGRATION_HTTPS_ENABLED", "false").lower() in ("true", "1") + self._disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") - self.configDirPath = os.getenv("UC_CONFIG_HOME") + self.config_dir_path = os.getenv("UC_CONFIG_HOME") - self.availableEntities = entities.Entities("available", self._loop) - self.configuredEntities = entities.Entities("configured", self._loop) + self.available_entities = entities.Entities("available", self._loop) + self.configured_entities = entities.Entities("configured", self._loop) # Setup event loop asyncio.set_event_loop(self._loop) - async def init(self, driver_path): + async def init(self, driver_path: str): """ Load driver configuration and start integration-API WebSocket server. :param driver_path: path to configuration file """ self._driver_path = driver_path - self._port = self.driverInfo["port"] if "port" in self.driverInfo else self._port + self._port = self.driver_info["port"] if "port" in self.driver_info else self._port - @self.configuredEntities.events.on(uc.EVENTS.ENTITY_ATTRIBUTES_UPDATED) + @self.configured_entities.events.on(uc.Events.ENTITY_ATTRIBUTES_UPDATED) async def event_handler(entity_id, entity_type, attributes): data = { "entity_id": entity_id, @@ -73,37 +74,37 @@ async def event_handler(entity_id, entity_type, attributes): "attributes": attributes, } - await self._broadcast_event(uc.MSG_EVENTS.ENTITY_CHANGE, data, uc.EVENT_CATEGORY.ENTITY) + await self._broadcast_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: - self.driverInfo = json.load(file) + self.driver_info = json.load(file) # Set driver URL # TODO verify _get_driver_url: logic might not be correct, # move all parameter logic inside method to better understand what this does - self.driverInfo["driver_url"] = self._get_driver_url( - self.driverInfo["driver_url"] if "driver_url" in self.driverInfo else self._interface, self._port + self.driver_info["driver_url"] = self._get_driver_url( + self.driver_info["driver_url"] if "driver_url" in self.driver_info else self._interface, self._port ) # Set driver name - name = _get_default_language_string(self.driverInfo["name"], "Unknown driver") + name = _get_default_language_string(self.driver_info["name"], "Unknown driver") # TODO there seems to be missing something with `url` # url = self._interface - addr = socket.gethostbyname(socket.gethostname()) if self.driverInfo["driver_url"] is None else self._interface + addr = socket.gethostbyname(socket.gethostname()) if self.driver_info["driver_url"] is None else self._interface if self._disable_mdns_publish is False: # Setup zeroconf service info info = AsyncServiceInfo( "_uc-integration._tcp.local.", - f"{self.driverInfo['driver_id']}._uc-integration._tcp.local.", + f"{self.driver_info['driver_id']}._uc-integration._tcp.local.", addresses=[addr], port=int(self._port), properties={ "name": name, - "ver": self.driverInfo["version"], - "developer": self.driverInfo["developer"]["name"], + "ver": self.driver_info["version"], + "developer": self.driver_info["developer"]["name"], }, ) zeroconf = AsyncZeroconf(ip_version=IPVersion.V4Only) @@ -113,9 +114,9 @@ async def event_handler(entity_id, entity_type, attributes): LOG.info( "Driver is up: %s, version: %s, listening on: %s", - self.driverInfo["driver_id"], - self.driverInfo["version"], - self.driverInfo["driver_url"], + self.driver_info["driver_id"], + self.driver_info["version"], + self.driver_info["driver_url"], ) def _get_driver_url(self, driver_url: str | None, port: int | str) -> str | None: @@ -152,21 +153,25 @@ async def _handle_ws(self, websocket): finally: self._clients.remove(websocket) LOG.info("WS: Client removed") - self.events.emit(uc.EVENTS.DISCONNECT) + self.events.emit(uc.Events.DISCONNECT) - async def _send_ok_result(self, websocket, req_id, msg_data={}): + async def _send_ok_result(self, websocket, req_id: str, msg_data: dict[str, Any] | None = None) -> None: await self._send_response(websocket, req_id, "result", msg_data, 200) - async def _send_error_result(self, websocket, req_id, status_code=500, msg_data={}): + async def _send_error_result( + self, websocket, req_id: int, status_code=500, msg_data: dict[str, Any] | None = None + ) -> None: await self._send_response(websocket, req_id, "result", msg_data, status_code) - async def _send_response(self, websocket, req_id, msg, msg_data, status_code=uc.STATUS_CODES.OK): + async def _send_response( + self, websocket, req_id: int, msg: str, msg_data: dict[str, Any] | None, status_code=uc.StatusCodes.OK + ) -> None: data = { "kind": "resp", "req_id": req_id, "code": int(status_code), "msg": msg, - "msg_data": msg_data, + "msg_data": msg_data if msg_data is not None else {}, } if websocket in self._clients: @@ -176,7 +181,7 @@ async def _send_response(self, websocket, req_id, msg, msg_data, status_code=uc. else: LOG.error("Error sending response: connection no longer established") - async def _broadcast_event(self, msg, msg_data, category): + async def _broadcast_event(self, msg: str, msg_data: dict[str, Any], category: str): data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} for websocket in self._clients: @@ -184,7 +189,7 @@ async def _broadcast_event(self, msg, msg_data, category): LOG.debug("->: %s", data_dump) await websocket.send(data_dump) - async def _send_event(self, websocket, msg, msg_data, category): + async def _send_event(self, websocket, msg: str, msg_data: dict[str, Any], category: str): data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} if websocket in self._clients: @@ -194,7 +199,7 @@ async def _send_event(self, websocket, msg, msg_data, category): else: LOG.error("Error sending event: connection no longer established") - async def _process_ws_message(self, websocket, message): + async def _process_ws_message(self, websocket, message) -> None: LOG.debug("<-: %s", message) data = json.loads(message) @@ -204,103 +209,123 @@ async def _process_ws_message(self, websocket, message): msg_data = data["msg_data"] if "msg_data" in data else None if kind == "req": - if msg == uc.MESSAGES.GET_DRIVER_VERSION: - await self._send_response(websocket, req_id, uc.MSG_EVENTS.DRIVER_VERSION, self.getDriverVersion()) - elif msg == uc.MESSAGES.GET_DEVICE_STATE: - await self._send_response(websocket, req_id, uc.MSG_EVENTS.DEVICE_STATE, self.state) - elif msg == uc.MESSAGES.GET_AVAILABLE_ENTITIES: - available_entities = self.availableEntities.getEntities() - await self._send_response( - websocket, - req_id, - uc.MSG_EVENTS.AVAILABLE_ENTITIES, - {"available_entities": available_entities}, - ) - elif msg == uc.MESSAGES.GET_ENTITY_STATES: - entity_states = await self.configuredEntities.getStates() - await self._send_response( - websocket, - req_id, - uc.MSG_EVENTS.ENTITY_STATES, - entity_states, - ) - elif msg == uc.MESSAGES.ENTITY_COMMAND: - await self._entity_command(websocket, req_id, msg_data) - elif msg == uc.MESSAGES.SUBSCRIBE_EVENTS: - await self._subscribe_events(msg_data) - await self._send_ok_result(websocket, req_id) - elif msg == uc.MESSAGES.UNSUBSCRIBE_EVENTS: - await self._unsubscribe_events(msg_data) - await self._send_ok_result(websocket, req_id) - elif msg == uc.MESSAGES.GET_DRIVER_METADATA: - await self._send_response(websocket, req_id, uc.MSG_EVENTS.DRIVER_METADATA, self.driverInfo) - elif msg == uc.MESSAGES.SETUP_DRIVER: - await self._setup_driver(websocket, req_id, msg_data) - elif msg == uc.MESSAGES.SET_DRIVER_USER_DATA: - await self._set_driver_user_data(websocket, req_id, msg_data) - + if req_id is None: + 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": - if msg == uc.MSG_EVENTS.CONNECT: - self.events.emit(uc.EVENTS.CONNECT) - elif msg == uc.MSG_EVENTS.DISCONNECT: - self.events.emit(uc.EVENTS.DISCONNECT) - elif msg == uc.MSG_EVENTS.ENTER_STANDBY: - self.events.emit(uc.EVENTS.ENTER_STANDBY) - elif msg == uc.MSG_EVENTS.EXIT_STANDBY: - self.events.emit(uc.EVENTS.EXIT_STANDBY) - elif msg == uc.MSG_EVENTS.ABORT_DRIVER_SETUP: - self.events.emit(uc.EVENTS.SETUP_DRIVER_ABORT) - - async def _authenticate(self, websocket, success): + 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: + if msg == uc.WsMessages.GET_DRIVER_VERSION: + await self._send_response(websocket, req_id, uc.WsMsgEvents.DRIVER_VERSION, self.get_driver_version()) + elif msg == uc.WsMessages.GET_DEVICE_STATE: + await self._send_response(websocket, req_id, uc.WsMsgEvents.DEVICE_STATE, self.state) + elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: + available_entities = self.available_entities.get_all() + await self._send_response( + websocket, + req_id, + uc.WsMsgEvents.AVAILABLE_ENTITIES, + {"available_entities": available_entities}, + ) + elif msg == uc.WsMessages.GET_ENTITY_STATES: + entity_states = await self.configured_entities.get_states() + await self._send_response( + websocket, + req_id, + uc.WsMsgEvents.ENTITY_STATES, + entity_states, + ) + elif msg == uc.WsMessages.ENTITY_COMMAND: + await self._entity_command(websocket, req_id, msg_data) + elif msg == uc.WsMessages.SUBSCRIBE_EVENTS: + await self._subscribe_events(msg_data) + await self._send_ok_result(websocket, req_id) + elif msg == uc.WsMessages.UNSUBSCRIBE_EVENTS: + await self._unsubscribe_events(msg_data) + await self._send_ok_result(websocket, req_id) + elif msg == uc.WsMessages.GET_DRIVER_METADATA: + await self._send_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) + elif msg == uc.WsMessages.SET_DRIVER_USER_DATA: + await self._set_driver_user_data(websocket, req_id, msg_data) + + def _handle_ws_event_msg(self, msg: str, _msg_data: dict[str, Any] | None) -> None: + if msg == uc.WsMsgEvents.CONNECT: + self.events.emit(uc.Events.CONNECT) + elif msg == uc.WsMsgEvents.DISCONNECT: + self.events.emit(uc.Events.DISCONNECT) + elif msg == uc.WsMsgEvents.ENTER_STANDBY: + self.events.emit(uc.Events.ENTER_STANDBY) + elif msg == uc.WsMsgEvents.EXIT_STANDBY: + self.events.emit(uc.Events.EXIT_STANDBY) + elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP: + self.events.emit(uc.Events.SETUP_DRIVER_ABORT) + + async def _authenticate(self, websocket, success: bool): await self._send_response( websocket, 0, - uc.MESSAGES.AUTHENTICATION, + uc.WsMessages.AUTHENTICATION, {}, - uc.STATUS_CODES.OK if success else uc.STATUS_CODES.UNAUTHORIZED, + uc.StatusCodes.OK if success else uc.StatusCodes.UNAUTHORIZED, ) - def getDriverVersion(self): + def get_driver_version(self) -> dict[str, dict[str, Any]]: + """Get driver version information.""" return { - "name": self.driverInfo["name"]["en"], + "name": self.driver_info["name"]["en"], "version": { - "api": self.driverInfo["min_core_api"], - "driver": self.driverInfo["version"], + "api": self.driver_info["min_core_api"], + "driver": self.driver_info["version"], }, } - async def setDeviceState(self, state): + async def set_device_state(self, state: uc.DeviceStates) -> None: + """Set new state.""" self.state = state - await self._broadcast_event(uc.MSG_EVENTS.DEVICE_STATE, {"state": self.state}, uc.EVENT_CATEGORY.DEVICE) + await self._broadcast_event(uc.WsMsgEvents.DEVICE_STATE, {"state": self.state}, uc.EventCategory.DEVICE) - async def _subscribe_events(self, subscribe): - for entityId in subscribe["entity_ids"]: - entity = self.availableEntities.getEntity(entityId) + async def _subscribe_events(self, msg_data: dict[str, Any] | None) -> None: + if msg_data is None: + LOG.warning("Ignoring _subscribe_events: called with empty msg_data") + return + for entity_id in msg_data["entity_ids"]: + entity = self.available_entities.get(entity_id) if entity is not None: - self.configuredEntities.addEntity(entity) + self.configured_entities.add(entity) else: LOG.warning( "WARN: cannot subscribe entity %s: entity is not available", - entityId, + entity_id, ) - self.events.emit(uc.EVENTS.SUBSCRIBE_ENTITIES, subscribe["entity_ids"]) + self.events.emit(uc.Events.SUBSCRIBE_ENTITIES, msg_data["entity_ids"]) + + async def _unsubscribe_events(self, msg_data: dict[str, Any] | None) -> bool: + if msg_data is None: + LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") + return False - async def _unsubscribe_events(self, unsubscribe): res = True - for entityId in unsubscribe["entity_ids"]: - if self.configuredEntities.removeEntity(entityId) is False: + for entity_id in msg_data["entity_ids"]: + if self.configured_entities.remove(entity_id) is False: res = False - self.events.emit(uc.EVENTS.UNSUBSCRIBE_ENTITIES, unsubscribe["entity_ids"]) + self.events.emit(uc.Events.UNSUBSCRIBE_ENTITIES, msg_data["entity_ids"]) return res - async def _entity_command(self, websocket, req_id, msg_data): + async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + if msg_data is None: + LOG.warning("Ignoring _entity_command: called with empty msg_data") + return self.events.emit( - uc.EVENTS.ENTITY_COMMAND, + uc.Events.ENTITY_COMMAND, websocket, req_id, msg_data["entity_id"], @@ -309,26 +334,32 @@ async def _entity_command(self, websocket, req_id, msg_data): msg_data["params"] if "params" in msg_data else None, ) - async def _setup_driver(self, websocket, req_id, data): - self.events.emit(uc.EVENTS.SETUP_DRIVER, websocket, req_id, data["setup_data"]) - - async def _set_driver_user_data(self, websocket, req_id, data): - if "input_values" in data: - self.events.emit(uc.EVENTS.SETUP_DRIVER_USER_DATA, websocket, req_id, data["input_values"]) - elif "confirm" in data: - self.events.emit(uc.EVENTS.SETUP_DRIVER_USER_CONFIRMATION, websocket, req_id, data=None) + 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 _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) else: LOG.warning("Unsupported set_driver_user_data payload received") - async def acknowledgeCommand(self, websocket, req_id, status_code=uc.STATUS_CODES.OK): + async def acknowledge_command(self, websocket, req_id: int, status_code=uc.StatusCodes.OK) -> None: + """Acknowledge a command from Remote Two.""" await self._send_response(websocket, req_id, "result", {}, status_code) - async def driverSetupProgress(self, websocket): + async def driver_setup_progress(self, websocket) -> None: + """Send a driver setup progress event to Remote Two.""" data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) + await self._send_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) - async def requestDriverSetupUserConfirmation(self, websocket, title, msg1=None, image=None, msg2=None): + async def request_driver_setup_user_confirmation(self, websocket, title, msg1=None, image=None, msg2=None) -> None: + """Request a user confirmation during the driver setup process.""" data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", @@ -342,29 +373,32 @@ async def requestDriverSetupUserConfirmation(self, websocket, title, msg1=None, }, } - await self._send_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) + await self._send_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) - async def requestDriverSetupUserInput(self, websocket, title, settings): + async def request_driver_setup_user_input(self, websocket, title, settings: dict[str, Any]) -> None: + """Request a user input during the driver setup process.""" data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, } - await self._send_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) + await self._send_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) - async def driverSetupComplete(self, websocket): + 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_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) + await self._send_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) - async def driverSetupError(self, websocket, error="OTHER"): + 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_event(websocket, uc.MSG_EVENTS.DRIVER_SETUP_CHANGE, data, uc.EVENT_CATEGORY.DEVICE) + await self._send_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) -def _to_language_object(text): +def _to_language_object(text: str | dict[str, str] | None): if text is None: return None if isinstance(text, str): @@ -373,7 +407,7 @@ def _to_language_object(text): return text -def _get_default_language_string(text, default_text="Undefined"): +def _get_default_language_string(text: str | dict[str, str] | None, default_text="Undefined") -> str: if text is None: return default_text @@ -384,7 +418,7 @@ def _get_default_language_string(text, default_text="Undefined"): if index == 0: default_text = value - if key.startswith("en-"): + if key.startswith("en_"): return text[key] return default_text diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index fac148f..644f022 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -2,13 +2,13 @@ API definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ from enum import Enum, IntEnum -class DEVICE_STATES(str, Enum): +class DeviceStates(str, Enum): """Device states.""" CONNECTED = "CONNECTED" @@ -17,7 +17,7 @@ class DEVICE_STATES(str, Enum): ERROR = "ERROR" -class STATUS_CODES(IntEnum): +class StatusCodes(IntEnum): """Response status codes.""" OK = 200 @@ -28,8 +28,8 @@ class STATUS_CODES(IntEnum): SERVICE_UNAVAILABLE = 503 -class MESSAGES(str, Enum): - """Request messages from Remote Two.""" +class WsMessages(str, Enum): + """WebSocket request messages from Remote Two.""" AUTHENTICATION = "authentication" GET_DRIVER_VERSION = "get_driver_version" @@ -44,8 +44,8 @@ class MESSAGES(str, Enum): SET_DRIVER_USER_DATA = "set_driver_user_data" -class MSG_EVENTS(str, Enum): - """Event messages from Remote Two.""" +class WsMsgEvents(str, Enum): + """WebSocket event messages from Remote Two.""" CONNECT = "connect" DISCONNECT = "disconnect" @@ -61,8 +61,8 @@ class MSG_EVENTS(str, Enum): ABORT_DRIVER_SETUP = "abort_driver_setup" -class EVENTS(str, Enum): - """Internal events.""" +class Events(str, Enum): + """Internal library events.""" ENTITY_COMMAND = "entity_command" ENTITY_ATTRIBUTES_UPDATED = "entity_attributes_updated" @@ -78,7 +78,7 @@ class EVENTS(str, Enum): EXIT_STANDBY = "exit_standby" -class EVENT_CATEGORY(str, Enum): +class EventCategory(str, Enum): """Event categories.""" DEVICE = "DEVICE" diff --git a/ucapi/button.py b/ucapi/button.py index 2db216a..040764f 100644 --- a/ucapi/button.py +++ b/ucapi/button.py @@ -2,33 +2,33 @@ Button entity definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import logging from enum import Enum -from ucapi.entity import TYPES, Entity +from ucapi.entity import Entity, Types logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES(str, Enum): +class States(str, Enum): """Button entity states.""" UNAVAILABLE = "UNAVAILABLE" AVAILABLE = "AVAILABLE" -class ATTRIBUTES(str, Enum): +class Attributes(str, Enum): """Button entity attributes.""" STATE = "state" -class COMMANDS(str, Enum): +class Commands(str, Enum): """Button entity commands.""" PUSH = "push" @@ -53,9 +53,9 @@ def __init__(self, identifier: str, name: str | dict, area: str | None = None): super().__init__( identifier, name, - TYPES.BUTTON, + Types.BUTTON, ["press"], - {ATTRIBUTES.STATE: STATES.AVAILABLE}, + {Attributes.STATE: States.AVAILABLE}, None, None, area, diff --git a/ucapi/climate.py b/ucapi/climate.py index c7ef139..be3cc88 100644 --- a/ucapi/climate.py +++ b/ucapi/climate.py @@ -2,20 +2,20 @@ Climate entity definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import logging from enum import Enum -from ucapi.entity import TYPES, Entity +from ucapi.entity import Entity, Types logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES(str, Enum): +class States(str, Enum): """Climate entity states.""" UNAVAILABLE = "UNAVAILABLE" @@ -28,7 +28,7 @@ class STATES(str, Enum): AUTO = "AUTO" -class FEATURES(str, Enum): +class Features(str, Enum): """Climate entity features.""" ON_OFF = "on_off" @@ -40,7 +40,7 @@ class FEATURES(str, Enum): FAN = "fan" -class ATTRIBUTES(str, Enum): +class Attributes(str, Enum): """Climate entity attributes.""" STATE = "state" @@ -51,7 +51,7 @@ class ATTRIBUTES(str, Enum): FAN_MODE = "fan_mode" -class COMMANDS(str, Enum): +class Commands(str, Enum): """Climate entity commands.""" ON = "on" @@ -62,11 +62,11 @@ class COMMANDS(str, Enum): FAN_MODE = "fan_mode" -class DEVICECLASSES(str, Enum): +class DeviceClasses(str, Enum): """Climate entity device classes.""" -class OPTIONS(str, Enum): +class Options(str, Enum): """Climate entity options.""" TEMPERATURE_UNIT = "temperature_unit" @@ -88,9 +88,9 @@ def __init__( self, identifier: str, name: str | dict, - features: list[FEATURES], + features: list[Features], attributes: dict, - deviceClass: str | None = None, + device_class: str | None = None, options: dict | None = None, area: str | None = None, ): @@ -101,17 +101,17 @@ def __init__( :param name: friendly name :param features: climate features :param attributes: climate attributes - :param deviceClass: optional climate device class + :param device_class: optional climate device class :param options: options :param area: optional area """ super().__init__( identifier, name, - TYPES.CLIMATE, + Types.CLIMATE, features, attributes, - deviceClass, + device_class, options, area, ) diff --git a/ucapi/cover.py b/ucapi/cover.py index f520ab4..ba6d102 100644 --- a/ucapi/cover.py +++ b/ucapi/cover.py @@ -2,20 +2,20 @@ Cover entity definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import logging from enum import Enum -from ucapi.entity import TYPES, Entity +from ucapi.entity import Entity, Types logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES(str, Enum): +class States(str, Enum): """Cover entity states.""" UNAVAILABLE = "UNAVAILABLE" @@ -26,7 +26,7 @@ class STATES(str, Enum): CLOSED = "CLOSED" -class FEATURES(str, Enum): +class Features(str, Enum): """Cover entity features.""" OPEN = "open" @@ -38,7 +38,7 @@ class FEATURES(str, Enum): TILT_POSITION = "tilt_position" -class ATTRIBUTES(str, Enum): +class Attributes(str, Enum): """Cover entity attributes.""" STATE = "state" @@ -46,7 +46,7 @@ class ATTRIBUTES(str, Enum): TILT_POSITION = "tilt_position" -class COMMANDS(str, Enum): +class Commands(str, Enum): """Cover entity commands.""" OPEN = "open" @@ -59,7 +59,7 @@ class COMMANDS(str, Enum): TILT_STOP = "tilt_stop" -class DEVICECLASSES(str, Enum): +class DeviceClasses(str, Enum): """Cover entity device classes.""" BLIND = "blind" @@ -71,7 +71,7 @@ class DEVICECLASSES(str, Enum): WINDOW = "window" -class OPTIONS(str, Enum): +class Options(str, Enum): """Cover entity options.""" @@ -87,9 +87,9 @@ def __init__( self, identifier: str, name: str | dict, - features: list[FEATURES], + features: list[Features], attributes: dict, - deviceClass: DEVICECLASSES | None = None, + device_class: DeviceClasses | None = None, options: dict | None = None, area: str | None = None, ): @@ -100,17 +100,17 @@ def __init__( :param name: friendly name :param features: cover features :param attributes: cover attributes - :param deviceClass: optional cover device class + :param device_class: optional cover device class :param options: options :param area: optional area """ super().__init__( identifier, name, - TYPES.COVER, + Types.COVER, features, attributes, - deviceClass, + device_class, options, area, ) diff --git a/ucapi/entities.py b/ucapi/entities.py index 21957a2..2ea5151 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -2,7 +2,7 @@ Entity store. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import logging @@ -10,7 +10,7 @@ from pyee import AsyncIOEventEmitter -from ucapi.api_definitions import EVENTS +from ucapi.api_definitions import Events from ucapi.entity import Entity logging.basicConfig() @@ -37,7 +37,7 @@ def contains(self, entity_id: str) -> bool: """Check if storage contains an entity with given identifier.""" return entity_id in self._storage - def getEntity(self, entity_id: str) -> Entity | None: + def get(self, entity_id: str) -> Entity | None: """Retrieve entity with given identifier.""" if entity_id not in self._storage: LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) @@ -45,7 +45,7 @@ def getEntity(self, entity_id: str) -> Entity | None: return self._storage[entity_id] - def addEntity(self, entity: Entity) -> bool: + def add(self, entity: Entity) -> bool: """Add entity to storage.""" if entity.id in self._storage: LOG.debug("ENTITIES(%s): Entity already exists with id: %s", self.id, entity.id) @@ -55,7 +55,7 @@ def addEntity(self, entity: Entity) -> bool: LOG.debug("ENTITIES(%s): Entity added with id: %s", self.id, entity.id) return True - def removeEntity(self, entity_id: str) -> bool: + def remove(self, entity_id: str) -> bool: """Remove entity from storage.""" if entity_id not in self._storage: LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) @@ -65,27 +65,26 @@ def removeEntity(self, entity_id: str) -> bool: LOG.debug("ENTITIES(%s): Entity deleted with id: %s", self.id, entity_id) return True - def updateEntityAttributes(self, entity_id: str, attributes: dict) -> bool: + def update_attributes(self, entity_id: str, attributes: dict) -> bool: """Update entity attributes.""" if entity_id not in self._storage: LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) - # TODO why return True here? - return True + return False for key in attributes: self._storage[entity_id].attributes[key] = attributes[key] self.events.emit( - EVENTS.ENTITY_ATTRIBUTES_UPDATED, + Events.ENTITY_ATTRIBUTES_UPDATED, entity_id, - self._storage[entity_id].entityType, + self._storage[entity_id].entity_type, attributes, ) LOG.debug("ENTITIES(%s): Entity attributes updated with id: %s", self.id, entity_id) return True - def getEntities(self) -> list[dict[str, any]]: + def get_all(self) -> list[dict[str, any]]: """ Get all entity information in storage. @@ -96,27 +95,27 @@ def getEntities(self) -> list[dict[str, any]]: for entity in self._storage.values(): res = { "entity_id": entity.id, - "entity_type": entity.entityType, - "device_id": entity.deviceId, + "entity_type": entity.entity_type, + "device_id": entity.device_id, "features": entity.features, "name": entity.name, "area": entity.area, - "device_class": entity.deviceClass, + "device_class": entity.device_class, } entities.append(res) return entities - async def getStates(self) -> list[dict[str, any]]: + async def get_states(self) -> list[dict[str, any]]: """Get all entity information with entity_id, entity_type, device_id, attributes.""" entities = [] for entity in self._storage.values(): res = { "entity_id": entity.id, - "entity_type": entity.entityType, - "device_id": entity.deviceId, + "entity_type": entity.entity_type, + "device_id": entity.device_id, "attributes": entity.attributes, } diff --git a/ucapi/entity.py b/ucapi/entity.py index 0aae614..faf9cb9 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -2,13 +2,13 @@ Entity definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ from enum import Enum -class TYPES(str, Enum): +class Types(str, Enum): """Entity types.""" COVER = "cover" @@ -32,7 +32,7 @@ def __init__( self, identifier: str, name: str | dict, - entity_type: TYPES, + entity_type: Types, features: list[str], attributes: dict, device_class: str | None, @@ -53,10 +53,10 @@ def __init__( """ self.id = identifier self.name = {"en": name} if isinstance(name, str) else name - self.entityType = entity_type - self.deviceId = None + self.entity_type = entity_type + self.device_id = None self.features = features self.attributes = attributes - self.deviceClass = device_class + self.device_class = device_class self.options = options self.area = area diff --git a/ucapi/light.py b/ucapi/light.py index 7f15383..ded057d 100644 --- a/ucapi/light.py +++ b/ucapi/light.py @@ -2,20 +2,20 @@ Light entity definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import logging from enum import Enum -from ucapi.entity import TYPES, Entity +from ucapi.entity import Entity, Types logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES(str, Enum): +class States(str, Enum): """Light entity states.""" UNAVAILABLE = "UNAVAILABLE" @@ -24,7 +24,7 @@ class STATES(str, Enum): OFF = "OFF" -class FEATURES(str, Enum): +class Features(str, Enum): """Light entity features.""" ON_OFF = "on_off" @@ -34,7 +34,7 @@ class FEATURES(str, Enum): COLOR_TEMPERATURE = "color_temperature" -class ATTRIBUTES(str, Enum): +class Attributes(str, Enum): """Light entity attributes.""" STATE = "state" @@ -44,7 +44,7 @@ class ATTRIBUTES(str, Enum): COLOR_TEMPERATURE = "color_temperature" -class COMMANDS(str, Enum): +class Commands(str, Enum): """Light entity commands.""" ON = "on" @@ -52,11 +52,11 @@ class COMMANDS(str, Enum): TOGGLE = "toggle" -class DEVICECLASSES(str, Enum): +class DeviceClasses(str, Enum): """Light entity device classes.""" -class OPTIONS(str, Enum): +class Options(str, Enum): """Light entity options.""" COLOR_TEMPERATURE_STEPS = "color_temperature_steps" @@ -74,9 +74,9 @@ def __init__( self, identifier: str, name: str | dict, - features: list[FEATURES], + features: list[Features], attributes: dict, - deviceClass: DEVICECLASSES | None = None, + device_class: DeviceClasses | None = None, options: dict | None = None, area: str | None = None, ): @@ -87,17 +87,17 @@ def __init__( :param name: friendly name :param features: light features :param attributes: light attributes - :param deviceClass: optional light device class + :param device_class: optional light device class :param options: options :param area: optional area """ super().__init__( identifier, name, - TYPES.LIGHT, + Types.LIGHT, features, attributes, - deviceClass, + device_class, options, area, ) diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 0bc86fa..6993429 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -2,20 +2,20 @@ Media-player entity definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import logging from enum import Enum -from ucapi.entity import TYPES, Entity +from ucapi.entity import Entity, Types logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES(str, Enum): +class States(str, Enum): """Media-player entity states.""" UNAVAILABLE = "UNAVAILABLE" @@ -28,7 +28,7 @@ class STATES(str, Enum): BUFFERING = "BUFFERING" -class FEATURES(str, Enum): +class Features(str, Enum): """Media-player entity features.""" ON_OFF = "on_off" @@ -63,7 +63,7 @@ class FEATURES(str, Enum): SELECT_SOUND_MODE = "select_sound_mode" -class ATTRIBUTES(str, Enum): +class Attributes(str, Enum): """Media-player entity attributes.""" STATE = "state" @@ -84,7 +84,7 @@ class ATTRIBUTES(str, Enum): SOUND_MODE_LIST = "sound_mode_list" -class COMMANDS(str, Enum): +class Commands(str, Enum): """Media-player entity commands.""" ON = "on" @@ -124,7 +124,7 @@ class COMMANDS(str, Enum): SEARCH = "search" -class DEVICECLASSES(str, Enum): +class DeviceClasses(str, Enum): """Media-player entity device classes.""" RECEIVER = "receiver" @@ -134,13 +134,13 @@ class DEVICECLASSES(str, Enum): TV = "tv" -class OPTIONS(str, Enum): +class Options(str, Enum): """Media-player entity options.""" VOLUME_STEPS = "volume_steps" -class MEDIA_TYPE(str, Enum): +class MediaType(str, Enum): """Media types.""" MUSIC = "MUSIC" @@ -162,9 +162,9 @@ def __init__( self, identifier: str, name: str | dict, - features: set[FEATURES], + features: set[Features], attributes: dict, - deviceClass: DEVICECLASSES | None = None, + device_class: DeviceClasses | None = None, options: dict | None = None, area: str | None = None, ): @@ -175,17 +175,17 @@ def __init__( :param name: friendly name :param features: media-player features :param attributes: media-player attributes - :param deviceClass: optional media-player device class + :param device_class: optional media-player device class :param options: options :param area: optional area """ super().__init__( identifier, name, - TYPES.MEDIA_PLAYER, + Types.MEDIA_PLAYER, features, attributes, - deviceClass, + device_class, options, area, ) diff --git a/ucapi/sensor.py b/ucapi/sensor.py index 256e088..8f8102e 100644 --- a/ucapi/sensor.py +++ b/ucapi/sensor.py @@ -2,20 +2,20 @@ Sensor entity definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import logging from enum import Enum -from ucapi.entity import TYPES, Entity +from ucapi.entity import Entity, Types logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES(str, Enum): +class States(str, Enum): """Sensor entity states.""" UNAVAILABLE = "UNAVAILABLE" @@ -23,11 +23,11 @@ class STATES(str, Enum): ON = "ON" -class FEATURES(str, Enum): +class Features(str, Enum): """Sensor entity features.""" -class ATTRIBUTES(str, Enum): +class Attributes(str, Enum): """Sensor entity attributes.""" STATE = "state" @@ -35,11 +35,11 @@ class ATTRIBUTES(str, Enum): UNIT = "unit" -class COMMANDS(str, Enum): +class Commands(str, Enum): """Sensor entity commands.""" -class DEVICECLASSES(str, Enum): +class DeviceClasses(str, Enum): """Sensor entity device classes.""" CUSTOM = "custom" @@ -52,7 +52,7 @@ class DEVICECLASSES(str, Enum): VOLTAGE = "voltage" -class OPTIONS(str, Enum): +class Options(str, Enum): """Sensor entity options.""" CUSTOM_UNIT = "custom_unit" @@ -74,9 +74,9 @@ def __init__( self, identifier: str, name: str | dict, - features: list[FEATURES], + features: list[Features], attributes: dict, - deviceClass: DEVICECLASSES | None = None, + device_class: DeviceClasses | None = None, options: dict | None = None, area: str | None = None, ): @@ -87,17 +87,17 @@ def __init__( :param name: friendly name :param features: sensor features :param attributes: sensor attributes - :param deviceClass: optional sensor device class + :param device_class: optional sensor device class :param options: options :param area: optional area """ super().__init__( identifier, name, - TYPES.SENSOR, + Types.SENSOR, features, attributes, - deviceClass, + device_class, options, area, ) diff --git a/ucapi/switch.py b/ucapi/switch.py index 5305021..ef60681 100644 --- a/ucapi/switch.py +++ b/ucapi/switch.py @@ -2,20 +2,20 @@ Switch entity definitions. :copyright: (c) 2023 by Unfolded Circle ApS. -:license: MPL 2.0, see LICENSE for more details. +:license: MPL-2.0, see LICENSE for more details. """ import logging from enum import Enum -from ucapi.entity import TYPES, Entity +from ucapi.entity import Entity, Types logging.basicConfig() LOG = logging.getLogger(__name__) LOG.setLevel(logging.DEBUG) -class STATES(str, Enum): +class States(str, Enum): """Switch entity states.""" UNAVAILABLE = "UNAVAILABLE" @@ -24,20 +24,20 @@ class STATES(str, Enum): OFF = "OFF" -class FEATURES(str, Enum): +class Features(str, Enum): """Switch entity features.""" ON_OFF = "on_off" TOGGLE = "toggle" -class ATTRIBUTES(str, Enum): +class Attributes(str, Enum): """Switch entity attributes.""" STATE = "state" -class COMMANDS(str, Enum): +class Commands(str, Enum): """Switch entity commands.""" ON = "on" @@ -45,14 +45,14 @@ class COMMANDS(str, Enum): TOGGLE = "toggle" -class DEVICECLASSES(str, Enum): +class DeviceClasses(str, Enum): """Switch entity device classes.""" OUTLET = "outlet" SWITCH = "switch" -class OPTIONS(str, Enum): +class Options(str, Enum): """Switch entity options.""" READABLE = "readable" @@ -70,9 +70,9 @@ def __init__( self, identifier: str, name: str | dict, - features: list[FEATURES], + features: list[Features], attributes: dict, - deviceClass: DEVICECLASSES | None = None, + device_class: DeviceClasses | None = None, options: dict | None = None, area: str | None = None, ): @@ -83,17 +83,17 @@ def __init__( :param name: friendly name :param features: switch features :param attributes: switch attributes - :param deviceClass: optional switch device class + :param device_class: optional switch device class :param options: options :param area: optional area """ super().__init__( identifier, name, - TYPES.SWITCH, + Types.SWITCH, features, attributes, - deviceClass, + device_class, options, area, ) From 952a95d15e907e4baa7888745d182d04922aeacb Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Thu, 26 Oct 2023 23:59:26 +0200 Subject: [PATCH 02/25] Set next minor release version --- pyproject.toml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c5cf74a..032f95c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ucapi" -version = "0.0.11" +version = "0.1.0" authors = [ {name = "Unfolded Circle ApS", email = "hello@unfoldedcircle.com"} ] diff --git a/setup.py b/setup.py index 8586c90..c60f49f 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ PACKAGE_NAME = "ucapi" HERE = path.abspath(path.dirname(__file__)) -VERSION = "0.0.11" +VERSION = "0.1.0" with open(path.join(HERE, "README.md"), encoding="utf-8") as f: long_description = f.read() From 2344cef69b1e6378d1408de6d6dabf0627ea5b10 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Thu, 26 Oct 2023 23:59:45 +0200 Subject: [PATCH 03/25] Add code style --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4192f9a..7d69381 100644 --- a/README.md +++ b/README.md @@ -15,5 +15,48 @@ Requires Python 3.10 or newer ### Local testing: ```console python3 setup.py bdist_wheel -pip3 install dist/ucapi-$VERSION-py3-none-any.whl +pip3 install --force-reinstall dist/ucapi-$VERSION-py3-none-any.whl +``` + +## Code Style + +- Code line length: 120 +- Use double quotes as default (don't mix and match for simple quoting, checked with pylint). + +Install tooling: +```console +pip3 install -r test-requirements.txt +``` + +### 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: +```console +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 +``` + +Linting integration in PyCharm/IntelliJ IDEA: +1. Install plugin [Pylint](https://plugins.jetbrains.com/plugin/11084-pylint) +2. Open Pylint window and run a scan: `Check Module` or `Check Current File` + +### Format Code +```console +python -m black ucapi --line-length 120 +``` + +PyCharm/IntelliJ IDEA integration: +1. Go to `Preferences or Settings -> Tools -> Black` +2. Configure: +- Python interpreter +- Use Black formatter: `On code reformat` & optionally `On save` +- Arguments: `--line-length 120` + +### Sort Imports + +```console +python -m isort ucapi/. ``` From 19a5dc18a73165166ffcc2d917a3674f3871017b Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 14:01:01 +0200 Subject: [PATCH 04/25] refactor: logging - Remove logging.basicConfig() calls. Must be done by client. - Make logging instances private. - Move entity creation log statement to base class. --- ucapi/api.py | 41 ++++++++++++++++++++-------------------- ucapi/api_definitions.py | 3 +++ ucapi/button.py | 11 ++--------- ucapi/climate.py | 11 ++--------- ucapi/cover.py | 11 ++--------- ucapi/entities.py | 19 +++++++++---------- ucapi/entity.py | 10 ++++++++-- ucapi/light.py | 11 ++--------- ucapi/media_player.py | 11 ++--------- ucapi/sensor.py | 11 ++--------- ucapi/switch.py | 11 ++--------- 11 files changed, 54 insertions(+), 96 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 9541c01..977db39 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -21,9 +21,8 @@ import ucapi.api_definitions as uc from ucapi import entities -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +_LOG = logging.getLogger(__name__) +_LOG.setLevel(logging.DEBUG) class IntegrationAPI: @@ -112,7 +111,7 @@ async def event_handler(entity_id, entity_type, attributes): self._server_task = self._loop.create_task(self._start_web_socket_server()) - LOG.info( + _LOG.info( "Driver is up: %s, version: %s, listening on: %s", self.driver_info["driver_id"], self.driver_info["version"], @@ -135,7 +134,7 @@ async def _start_web_socket_server(self): async def _handle_ws(self, websocket): try: self._clients.add(websocket) - LOG.info("WS: Client added") + _LOG.info("WS: Client added") # authenticate on connection await self._authenticate(websocket, True) @@ -145,14 +144,14 @@ async def _handle_ws(self, websocket): await self._process_ws_message(websocket, message) except websockets.ConnectionClosedOK: - LOG.info("WS: Connection Closed") + _LOG.info("WS: Connection Closed") except websockets.exceptions.ConnectionClosedError: - LOG.info("WS: Connection Closed") + _LOG.info("WS: Connection Closed") finally: self._clients.remove(websocket) - LOG.info("WS: Client removed") + _LOG.info("WS: Client removed") self.events.emit(uc.Events.DISCONNECT) async def _send_ok_result(self, websocket, req_id: str, msg_data: dict[str, Any] | None = None) -> None: @@ -176,17 +175,17 @@ async def _send_response( if websocket in self._clients: data_dump = json.dumps(data) - LOG.debug("->: %s", data_dump) + _LOG.debug("->: %s", data_dump) await websocket.send(data_dump) else: - LOG.error("Error sending response: connection no longer established") + _LOG.error("Error sending response: connection no longer established") async def _broadcast_event(self, msg: str, msg_data: dict[str, Any], category: str): data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} for websocket in self._clients: data_dump = json.dumps(data) - LOG.debug("->: %s", data_dump) + _LOG.debug("->: %s", data_dump) await websocket.send(data_dump) async def _send_event(self, websocket, msg: str, msg_data: dict[str, Any], category: str): @@ -194,13 +193,13 @@ async def _send_event(self, websocket, msg: str, msg_data: dict[str, Any], categ if websocket in self._clients: data_dump = json.dumps(data) - LOG.debug("->: %s", data_dump) + _LOG.debug("->: %s", data_dump) await websocket.send(data_dump) else: - LOG.error("Error sending event: connection no longer established") + _LOG.error("Error sending event: connection no longer established") async def _process_ws_message(self, websocket, message) -> None: - LOG.debug("<-: %s", message) + _LOG.debug("<-: %s", message) data = json.loads(message) kind = data["kind"] @@ -210,7 +209,7 @@ 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": @@ -291,14 +290,14 @@ async def set_device_state(self, state: uc.DeviceStates) -> None: async def _subscribe_events(self, msg_data: dict[str, Any] | None) -> None: if msg_data is None: - LOG.warning("Ignoring _subscribe_events: called with empty msg_data") + _LOG.warning("Ignoring _subscribe_events: called with empty msg_data") return for entity_id in msg_data["entity_ids"]: entity = self.available_entities.get(entity_id) if entity is not None: self.configured_entities.add(entity) else: - LOG.warning( + _LOG.warning( "WARN: cannot subscribe entity %s: entity is not available", entity_id, ) @@ -307,7 +306,7 @@ async def _subscribe_events(self, msg_data: dict[str, Any] | None) -> None: async def _unsubscribe_events(self, msg_data: dict[str, Any] | None) -> bool: if msg_data is None: - LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") + _LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") return False res = True @@ -322,7 +321,7 @@ async def _unsubscribe_events(self, msg_data: dict[str, Any] | None) -> bool: async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if msg_data is None: - LOG.warning("Ignoring _entity_command: called with empty msg_data") + _LOG.warning("Ignoring _entity_command: called with empty msg_data") return self.events.emit( uc.Events.ENTITY_COMMAND, @@ -336,7 +335,7 @@ async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] 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") + _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"]) @@ -346,7 +345,7 @@ async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str elif "confirm" in msg_data: self.events.emit(uc.Events.SETUP_DRIVER_USER_CONFIRMATION, websocket, req_id, data=None) else: - LOG.warning("Unsupported set_driver_user_data payload received") + _LOG.warning("Unsupported set_driver_user_data payload received") async def acknowledge_command(self, websocket, req_id: int, status_code=uc.StatusCodes.OK) -> None: """Acknowledge a command from Remote Two.""" diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 644f022..e8fabc4 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -28,6 +28,7 @@ class StatusCodes(IntEnum): SERVICE_UNAVAILABLE = 503 +# Does WsMessages need to be public? class WsMessages(str, Enum): """WebSocket request messages from Remote Two.""" @@ -44,6 +45,7 @@ class WsMessages(str, Enum): SET_DRIVER_USER_DATA = "set_driver_user_data" +# Does WsMsgEvents need to be public? class WsMsgEvents(str, Enum): """WebSocket event messages from Remote Two.""" @@ -78,6 +80,7 @@ class Events(str, Enum): EXIT_STANDBY = "exit_standby" +# Does EventCategory need to be public? class EventCategory(str, Enum): """Event categories.""" diff --git a/ucapi/button.py b/ucapi/button.py index 040764f..f36162d 100644 --- a/ucapi/button.py +++ b/ucapi/button.py @@ -5,14 +5,9 @@ :license: MPL-2.0, see LICENSE for more details. """ -import logging from enum import Enum -from ucapi.entity import Entity, Types - -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +from ucapi.entity import Entity, EntityTypes class States(str, Enum): @@ -53,12 +48,10 @@ def __init__(self, identifier: str, name: str | dict, area: str | None = None): super().__init__( identifier, name, - Types.BUTTON, + EntityTypes.BUTTON, ["press"], {Attributes.STATE: States.AVAILABLE}, None, None, area, ) - - LOG.debug("Button entity created with id: %s", self.id) diff --git a/ucapi/climate.py b/ucapi/climate.py index be3cc88..da1212d 100644 --- a/ucapi/climate.py +++ b/ucapi/climate.py @@ -5,14 +5,9 @@ :license: MPL-2.0, see LICENSE for more details. """ -import logging from enum import Enum -from ucapi.entity import Entity, Types - -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +from ucapi.entity import Entity, EntityTypes class States(str, Enum): @@ -108,12 +103,10 @@ def __init__( super().__init__( identifier, name, - Types.CLIMATE, + EntityTypes.CLIMATE, features, attributes, device_class, options, area, ) - - LOG.debug("Climate entity created with id: %s", self.id) diff --git a/ucapi/cover.py b/ucapi/cover.py index ba6d102..4300a14 100644 --- a/ucapi/cover.py +++ b/ucapi/cover.py @@ -5,14 +5,9 @@ :license: MPL-2.0, see LICENSE for more details. """ -import logging from enum import Enum -from ucapi.entity import Entity, Types - -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +from ucapi.entity import Entity, EntityTypes class States(str, Enum): @@ -107,12 +102,10 @@ def __init__( super().__init__( identifier, name, - Types.COVER, + EntityTypes.COVER, features, attributes, device_class, options, area, ) - - LOG.debug("Cover entity created with id: %s", self.id) diff --git a/ucapi/entities.py b/ucapi/entities.py index 2ea5151..bffa6c2 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -13,9 +13,8 @@ from ucapi.api_definitions import Events from ucapi.entity import Entity -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +_LOG = logging.getLogger(__name__) +_LOG.setLevel(logging.DEBUG) class Entities: @@ -40,7 +39,7 @@ def contains(self, entity_id: str) -> bool: def get(self, entity_id: str) -> Entity | None: """Retrieve entity with given identifier.""" if entity_id not in self._storage: - LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) return None return self._storage[entity_id] @@ -48,27 +47,27 @@ def get(self, entity_id: str) -> Entity | None: def add(self, entity: Entity) -> bool: """Add entity to storage.""" if entity.id in self._storage: - LOG.debug("ENTITIES(%s): Entity already exists with id: %s", self.id, entity.id) + _LOG.debug("ENTITIES(%s): Entity already exists with id: %s", self.id, entity.id) return False self._storage[entity.id] = entity - LOG.debug("ENTITIES(%s): Entity added with id: %s", self.id, entity.id) + _LOG.debug("ENTITIES(%s): Entity added with id: %s", self.id, entity.id) return True def remove(self, entity_id: str) -> bool: """Remove entity from storage.""" if entity_id not in self._storage: - LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) return True del self._storage[entity_id] - LOG.debug("ENTITIES(%s): Entity deleted with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity deleted with id: %s", self.id, entity_id) return True def update_attributes(self, entity_id: str, attributes: dict) -> bool: """Update entity attributes.""" if entity_id not in self._storage: - LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) return False for key in attributes: @@ -81,7 +80,7 @@ def update_attributes(self, entity_id: str, attributes: dict) -> bool: attributes, ) - LOG.debug("ENTITIES(%s): Entity attributes updated with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity attributes updated with id: %s", self.id, entity_id) return True def get_all(self) -> list[dict[str, any]]: diff --git a/ucapi/entity.py b/ucapi/entity.py index faf9cb9..ec9255d 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -5,10 +5,14 @@ :license: MPL-2.0, see LICENSE for more details. """ +import logging from enum import Enum +_LOG = logging.getLogger(__name__) +_LOG.setLevel(logging.DEBUG) -class Types(str, Enum): + +class EntityTypes(str, Enum): """Entity types.""" COVER = "cover" @@ -32,7 +36,7 @@ def __init__( self, identifier: str, name: str | dict, - entity_type: Types, + entity_type: EntityTypes, features: list[str], attributes: dict, device_class: str | None, @@ -60,3 +64,5 @@ def __init__( self.device_class = device_class self.options = options self.area = area + + _LOG.debug("%s entity created with id: %s", self.entity_type, self.id) diff --git a/ucapi/light.py b/ucapi/light.py index ded057d..fa2ba2e 100644 --- a/ucapi/light.py +++ b/ucapi/light.py @@ -5,14 +5,9 @@ :license: MPL-2.0, see LICENSE for more details. """ -import logging from enum import Enum -from ucapi.entity import Entity, Types - -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +from ucapi.entity import Entity, EntityTypes class States(str, Enum): @@ -94,12 +89,10 @@ def __init__( super().__init__( identifier, name, - Types.LIGHT, + EntityTypes.LIGHT, features, attributes, device_class, options, area, ) - - LOG.debug("Light entity created with id: %s", self.id) diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 6993429..347f15a 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -5,14 +5,9 @@ :license: MPL-2.0, see LICENSE for more details. """ -import logging from enum import Enum -from ucapi.entity import Entity, Types - -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +from ucapi.entity import Entity, EntityTypes class States(str, Enum): @@ -182,12 +177,10 @@ def __init__( super().__init__( identifier, name, - Types.MEDIA_PLAYER, + EntityTypes.MEDIA_PLAYER, features, attributes, device_class, options, area, ) - - LOG.debug("MediaPlayer entity created with id: %s", self.id) diff --git a/ucapi/sensor.py b/ucapi/sensor.py index 8f8102e..83af1a6 100644 --- a/ucapi/sensor.py +++ b/ucapi/sensor.py @@ -5,14 +5,9 @@ :license: MPL-2.0, see LICENSE for more details. """ -import logging from enum import Enum -from ucapi.entity import Entity, Types - -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +from ucapi.entity import Entity, EntityTypes class States(str, Enum): @@ -94,12 +89,10 @@ def __init__( super().__init__( identifier, name, - Types.SENSOR, + EntityTypes.SENSOR, features, attributes, device_class, options, area, ) - - LOG.debug("Sensor entity created with id: %s", self.id) diff --git a/ucapi/switch.py b/ucapi/switch.py index ef60681..0fe96c8 100644 --- a/ucapi/switch.py +++ b/ucapi/switch.py @@ -5,14 +5,9 @@ :license: MPL-2.0, see LICENSE for more details. """ -import logging from enum import Enum -from ucapi.entity import Entity, Types - -logging.basicConfig() -LOG = logging.getLogger(__name__) -LOG.setLevel(logging.DEBUG) +from ucapi.entity import Entity, EntityTypes class States(str, Enum): @@ -90,12 +85,10 @@ def __init__( super().__init__( identifier, name, - Types.SWITCH, + EntityTypes.SWITCH, features, attributes, device_class, options, area, ) - - LOG.debug("Switch entity created with id: %s", self.id) From d78d091574fb6c9406a014a0ffe3e27d0543171d Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 14:01:40 +0200 Subject: [PATCH 05/25] fix: Markdown format --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7d69381..0fe3e2c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Requires Python 3.10 or newer --- ### Local testing: -```console +```shell python3 setup.py bdist_wheel pip3 install --force-reinstall dist/ucapi-$VERSION-py3-none-any.whl ``` @@ -24,7 +24,7 @@ pip3 install --force-reinstall dist/ucapi-$VERSION-py3-none-any.whl - Use double quotes as default (don't mix and match for simple quoting, checked with pylint). Install tooling: -```console +```shell pip3 install -r test-requirements.txt ``` @@ -32,7 +32,7 @@ pip3 install -r test-requirements.txt 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: -```console +```shell python -m pylint ucapi python -m flake8 ucapi --count --show-source --statistics python -m isort ucapi/. --check --verbose @@ -44,7 +44,7 @@ Linting integration in PyCharm/IntelliJ IDEA: 2. Open Pylint window and run a scan: `Check Module` or `Check Current File` ### Format Code -```console +```shell python -m black ucapi --line-length 120 ``` @@ -57,6 +57,6 @@ PyCharm/IntelliJ IDEA integration: ### Sort Imports -```console +```shell python -m isort ucapi/. ``` From f34cfd4518cd51e41c802eb4b45bb540d745cacc Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 14:03:38 +0200 Subject: [PATCH 06/25] fix: workaround for pylint errors in websocket --- ucapi/api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 977db39..c870041 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -15,6 +15,12 @@ import websockets from pyee import AsyncIOEventEmitter + +# workaround for pylint error: E0611: No name 'ConnectionClosedOK' in module 'websockets' (no-name-in-module) +from websockets.exceptions import ConnectionClosedOK + +# workaround for pylint error: E1101: Module 'websockets' has no 'serve' member (no-member) +from websockets.server import serve from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -128,7 +134,7 @@ def _get_driver_url(self, driver_url: str | None, port: int | str) -> str | None return None async def _start_web_socket_server(self): - async with websockets.serve(self._handle_ws, self._interface, int(self._port)): + async with serve(self._handle_ws, self._interface, int(self._port)): await asyncio.Future() async def _handle_ws(self, websocket): @@ -143,7 +149,7 @@ async def _handle_ws(self, websocket): # process message await self._process_ws_message(websocket, message) - except websockets.ConnectionClosedOK: + except ConnectionClosedOK: _LOG.info("WS: Connection Closed") except websockets.exceptions.ConnectionClosedError: From 7d5c6f9ac4dded79480f19c729e106dadda8f5be Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 14:04:10 +0200 Subject: [PATCH 07/25] fix: __init__ imports --- ucapi/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/ucapi/__init__.py b/ucapi/__init__.py index 32b2270..5dfdd66 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -6,3 +6,22 @@ :copyright: (c) 2023 by Unfolded Circle ApS. :license: MPL-2.0, see LICENSE for more details. """ + +# 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 .entity import EntityTypes # isort:skip # noqa: F401 +from .entities import Entities # isort:skip # noqa: F401 +from .api import IntegrationAPI # isort:skip # noqa: F401 + +# Entity types +from .button import Button # noqa: F401 +from .climate import Climate # noqa: F401 +from .cover import Cover # noqa: F401 +from .light import Light # noqa: F401 +from .media_player import MediaPlayer # noqa: F401 +from .sensor import Sensor # noqa: F401 +from .switch import Switch # noqa: F401 + +logging.getLogger(__name__).addHandler(logging.NullHandler()) From 54039bb40e0a416543b6b13f3362b04743869458 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 14:49:02 +0200 Subject: [PATCH 08/25] chore: disable pylint R0801 duplicate code --- ucapi/climate.py | 1 + ucapi/cover.py | 1 + ucapi/light.py | 1 + ucapi/media_player.py | 1 + ucapi/sensor.py | 1 + 5 files changed, 5 insertions(+) diff --git a/ucapi/climate.py b/ucapi/climate.py index da1212d..9633fe4 100644 --- a/ucapi/climate.py +++ b/ucapi/climate.py @@ -1,3 +1,4 @@ +# pylint: disable=R0801 """ Climate entity definitions. diff --git a/ucapi/cover.py b/ucapi/cover.py index 4300a14..19325b7 100644 --- a/ucapi/cover.py +++ b/ucapi/cover.py @@ -1,3 +1,4 @@ +# pylint: disable=R0801 """ Cover entity definitions. diff --git a/ucapi/light.py b/ucapi/light.py index fa2ba2e..c0cf06c 100644 --- a/ucapi/light.py +++ b/ucapi/light.py @@ -1,3 +1,4 @@ +# pylint: disable=R0801 """ Light entity definitions. diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 347f15a..057da3a 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -1,3 +1,4 @@ +# pylint: disable=R0801 """ Media-player entity definitions. diff --git a/ucapi/sensor.py b/ucapi/sensor.py index 83af1a6..2bb87f6 100644 --- a/ucapi/sensor.py +++ b/ucapi/sensor.py @@ -1,3 +1,4 @@ +# pylint: disable=R0801 """ Sensor entity definitions. From 5ed4db1577590c824ceea7c4b19e2bc7fdd607f8 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 14:57:02 +0200 Subject: [PATCH 09/25] refactor: don't expose any class fields, use properties --- ucapi/api.py | 141 ++++++++++++++++++++++++++++------------------ ucapi/entities.py | 34 +++++++---- 2 files changed, 111 insertions(+), 64 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index c870041..a379666 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -25,7 +25,8 @@ from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf import ucapi.api_definitions as uc -from ucapi import entities + +from .entities import Entities _LOG = logging.getLogger(__name__) _LOG.setLevel(logging.DEBUG) @@ -41,23 +42,22 @@ def __init__(self, loop: AbstractEventLoop): :param loop: event loop """ self._loop = loop - self.events = AsyncIOEventEmitter(self._loop) - self.driver_info = {} + self._events = AsyncIOEventEmitter(self._loop) + self._driver_info: dict[str, Any] = {} self._driver_path: str | None = None - self.state: uc.DeviceStates = uc.DeviceStates.DISCONNECTED + self._state: uc.DeviceStates = uc.DeviceStates.DISCONNECTED self._server_task = None self._clients = set() - self._interface = os.getenv("UC_INTEGRATION_INTERFACE") - self._port = os.getenv("UC_INTEGRATION_HTTP_PORT") - # TODO: add support for secured - self._https_enabled = os.getenv("UC_INTEGRATION_HTTPS_ENABLED", "false").lower() in ("true", "1") - self._disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") + self._interface: str | None = os.getenv("UC_INTEGRATION_INTERFACE") + self._port: int | str | None = os.getenv("UC_INTEGRATION_HTTP_PORT") + self._https_enabled: bool = os.getenv("UC_INTEGRATION_HTTPS_ENABLED", "false").lower() in ("true", "1") + self._disable_mdns_publish: bool = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") - self.config_dir_path = os.getenv("UC_CONFIG_HOME") + self._config_dir_path: str | None = os.getenv("UC_CONFIG_HOME") - self.available_entities = entities.Entities("available", self._loop) - self.configured_entities = entities.Entities("configured", self._loop) + self._available_entities = Entities("available", self._loop) + self._configured_entities = Entities("configured", self._loop) # Setup event loop asyncio.set_event_loop(self._loop) @@ -69,9 +69,9 @@ async def init(self, driver_path: str): :param driver_path: path to configuration file """ self._driver_path = driver_path - self._port = self.driver_info["port"] if "port" in self.driver_info else self._port + self._port = self._driver_info["port"] if "port" in self._driver_info else self._port - @self.configured_entities.events.on(uc.Events.ENTITY_ATTRIBUTES_UPDATED) + @self._configured_entities.events.on(uc.Events.ENTITY_ATTRIBUTES_UPDATED) async def event_handler(entity_id, entity_type, attributes): data = { "entity_id": entity_id, @@ -83,33 +83,36 @@ async def event_handler(entity_id, entity_type, attributes): # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: - self.driver_info = json.load(file) + self._driver_info = json.load(file) # Set driver URL # TODO verify _get_driver_url: logic might not be correct, # move all parameter logic inside method to better understand what this does - self.driver_info["driver_url"] = self._get_driver_url( - self.driver_info["driver_url"] if "driver_url" in self.driver_info else self._interface, self._port + self._driver_info["driver_url"] = self._get_driver_url( + self._driver_info["driver_url"] if "driver_url" in self._driver_info else self._interface, self._port ) # Set driver name - name = _get_default_language_string(self.driver_info["name"], "Unknown driver") + name = _get_default_language_string(self._driver_info["name"], "Unknown driver") # TODO there seems to be missing something with `url` # url = self._interface - addr = socket.gethostbyname(socket.gethostname()) if self.driver_info["driver_url"] is None else self._interface + # TODO: add support for secured WS + addr = ( + socket.gethostbyname(socket.gethostname()) if self._driver_info["driver_url"] is None else self._interface + ) if self._disable_mdns_publish is False: # Setup zeroconf service info info = AsyncServiceInfo( "_uc-integration._tcp.local.", - f"{self.driver_info['driver_id']}._uc-integration._tcp.local.", + f"{self._driver_info['driver_id']}._uc-integration._tcp.local.", addresses=[addr], port=int(self._port), properties={ "name": name, - "ver": self.driver_info["version"], - "developer": self.driver_info["developer"]["name"], + "ver": self._driver_info["version"], + "developer": self._driver_info["developer"]["name"], }, ) zeroconf = AsyncZeroconf(ip_version=IPVersion.V4Only) @@ -119,9 +122,9 @@ async def event_handler(entity_id, entity_type, attributes): _LOG.info( "Driver is up: %s, version: %s, listening on: %s", - self.driver_info["driver_id"], - self.driver_info["version"], - self.driver_info["driver_url"], + self._driver_info["driver_id"], + self._driver_info["version"], + self._driver_info["driver_url"], ) def _get_driver_url(self, driver_url: str | None, port: int | str) -> str | None: @@ -133,11 +136,11 @@ def _get_driver_url(self, driver_url: str | None, port: int | str) -> str | None return None - async def _start_web_socket_server(self): + async def _start_web_socket_server(self) -> None: async with serve(self._handle_ws, self._interface, int(self._port)): await asyncio.Future() - async def _handle_ws(self, websocket): + async def _handle_ws(self, websocket) -> None: try: self._clients.add(websocket) _LOG.info("WS: Client added") @@ -158,7 +161,7 @@ async def _handle_ws(self, websocket): finally: self._clients.remove(websocket) _LOG.info("WS: Client removed") - self.events.emit(uc.Events.DISCONNECT) + self._events.emit(uc.Events.DISCONNECT) async def _send_ok_result(self, websocket, req_id: str, msg_data: dict[str, Any] | None = None) -> None: await self._send_response(websocket, req_id, "result", msg_data, 200) @@ -186,7 +189,7 @@ async def _send_response( else: _LOG.error("Error sending response: connection no longer established") - async def _broadcast_event(self, msg: str, msg_data: dict[str, Any], category: str): + async def _broadcast_event(self, msg: str, msg_data: dict[str, Any], category: str) -> None: data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} for websocket in self._clients: @@ -194,7 +197,7 @@ async def _broadcast_event(self, msg: str, msg_data: dict[str, Any], category: s _LOG.debug("->: %s", data_dump) await websocket.send(data_dump) - async def _send_event(self, websocket, msg: str, msg_data: dict[str, Any], category: str): + async def _send_event(self, websocket, msg: str, msg_data: dict[str, Any], category: str) -> None: data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} if websocket in self._clients: @@ -227,7 +230,7 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat elif msg == uc.WsMessages.GET_DEVICE_STATE: await self._send_response(websocket, req_id, uc.WsMsgEvents.DEVICE_STATE, self.state) elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: - available_entities = self.available_entities.get_all() + available_entities = self._available_entities.get_all() await self._send_response( websocket, req_id, @@ -235,7 +238,7 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat {"available_entities": available_entities}, ) elif msg == uc.WsMessages.GET_ENTITY_STATES: - entity_states = await self.configured_entities.get_states() + entity_states = await self._configured_entities.get_states() await self._send_response( websocket, req_id, @@ -251,7 +254,7 @@ 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_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self.driver_info) + await self._send_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) elif msg == uc.WsMessages.SET_DRIVER_USER_DATA: @@ -259,17 +262,17 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat def _handle_ws_event_msg(self, msg: str, _msg_data: dict[str, Any] | None) -> None: if msg == uc.WsMsgEvents.CONNECT: - self.events.emit(uc.Events.CONNECT) + self._events.emit(uc.Events.CONNECT) elif msg == uc.WsMsgEvents.DISCONNECT: - self.events.emit(uc.Events.DISCONNECT) + self._events.emit(uc.Events.DISCONNECT) elif msg == uc.WsMsgEvents.ENTER_STANDBY: - self.events.emit(uc.Events.ENTER_STANDBY) + self._events.emit(uc.Events.ENTER_STANDBY) elif msg == uc.WsMsgEvents.EXIT_STANDBY: - self.events.emit(uc.Events.EXIT_STANDBY) + self._events.emit(uc.Events.EXIT_STANDBY) elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP: - self.events.emit(uc.Events.SETUP_DRIVER_ABORT) + self._events.emit(uc.Events.SETUP_DRIVER_ABORT) - async def _authenticate(self, websocket, success: bool): + async def _authenticate(self, websocket, success: bool) -> None: await self._send_response( websocket, 0, @@ -281,16 +284,17 @@ async def _authenticate(self, websocket, success: bool): def get_driver_version(self) -> dict[str, dict[str, Any]]: """Get driver version information.""" return { - "name": self.driver_info["name"]["en"], + "name": self._driver_info["name"]["en"], "version": { - "api": self.driver_info["min_core_api"], - "driver": self.driver_info["version"], + "api": self._driver_info["min_core_api"], + "driver": self._driver_info["version"], }, } + # TODO use a property setter? async def set_device_state(self, state: uc.DeviceStates) -> None: """Set new state.""" - self.state = state + self._state = state await self._broadcast_event(uc.WsMsgEvents.DEVICE_STATE, {"state": self.state}, uc.EventCategory.DEVICE) @@ -299,16 +303,16 @@ async def _subscribe_events(self, msg_data: dict[str, Any] | None) -> None: _LOG.warning("Ignoring _subscribe_events: called with empty msg_data") return for entity_id in msg_data["entity_ids"]: - entity = self.available_entities.get(entity_id) + entity = self._available_entities.get(entity_id) if entity is not None: - self.configured_entities.add(entity) + self._configured_entities.add(entity) else: _LOG.warning( "WARN: cannot subscribe entity %s: entity is not available", entity_id, ) - self.events.emit(uc.Events.SUBSCRIBE_ENTITIES, msg_data["entity_ids"]) + self._events.emit(uc.Events.SUBSCRIBE_ENTITIES, msg_data["entity_ids"]) async def _unsubscribe_events(self, msg_data: dict[str, Any] | None) -> bool: if msg_data is None: @@ -318,10 +322,10 @@ async def _unsubscribe_events(self, msg_data: dict[str, Any] | None) -> bool: res = True for entity_id in msg_data["entity_ids"]: - if self.configured_entities.remove(entity_id) is False: + if self._configured_entities.remove(entity_id) is False: res = False - self.events.emit(uc.Events.UNSUBSCRIBE_ENTITIES, msg_data["entity_ids"]) + self._events.emit(uc.Events.UNSUBSCRIBE_ENTITIES, msg_data["entity_ids"]) return res @@ -329,7 +333,7 @@ async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] if msg_data is None: _LOG.warning("Ignoring _entity_command: called with empty msg_data") return - self.events.emit( + self._events.emit( uc.Events.ENTITY_COMMAND, websocket, req_id, @@ -343,13 +347,13 @@ async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | 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"]) + self._events.emit(uc.Events.SETUP_DRIVER, websocket, req_id, msg_data["setup_data"]) 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"]) + 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) + self._events.emit(uc.Events.SETUP_DRIVER_USER_CONFIRMATION, websocket, req_id, data=None) else: _LOG.warning("Unsupported set_driver_user_data payload received") @@ -402,8 +406,37 @@ async def driver_setup_error(self, websocket, error="OTHER") -> None: await self._send_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) - -def _to_language_object(text: str | dict[str, str] | None): + ############## + # Properties # + ############## + # TODO redesign event callback: don't expose AsyncIOEventEmitter! The client may not emit events!!! + @property + def events(self) -> AsyncIOEventEmitter: + """Return event emitter.""" + return self._events + + @property + def state(self) -> str: + """Return driver state.""" + return self._state + + @property + def config_dir_path(self) -> str | None: + """Return configuration directory path.""" + return self._config_dir_path + + @property + def available_entities(self) -> Entities: + """Return the available entities.""" + return self._available_entities + + @property + def configured_entities(self) -> Entities: + """Return the configured entities.""" + return self._configured_entities + + +def _to_language_object(text: str | dict[str, str] | None) -> dict[str, str] | None: if text is None: return None if isinstance(text, str): diff --git a/ucapi/entities.py b/ucapi/entities.py index bffa6c2..935cdce 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -27,10 +27,10 @@ def __init__(self, identifier: str, loop: AbstractEventLoop): :param identifier: storage identifier. :param loop: event loop """ - self.id = identifier + self._id = identifier self._loop = loop self._storage = {} - self.events = AsyncIOEventEmitter(self._loop) + self._events = AsyncIOEventEmitter(self._loop) def contains(self, entity_id: str) -> bool: """Check if storage contains an entity with given identifier.""" @@ -39,7 +39,7 @@ def contains(self, entity_id: str) -> bool: def get(self, entity_id: str) -> Entity | None: """Retrieve entity with given identifier.""" if entity_id not in self._storage: - _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self._id, entity_id) return None return self._storage[entity_id] @@ -47,40 +47,40 @@ def get(self, entity_id: str) -> Entity | None: def add(self, entity: Entity) -> bool: """Add entity to storage.""" if entity.id in self._storage: - _LOG.debug("ENTITIES(%s): Entity already exists with id: %s", self.id, entity.id) + _LOG.debug("ENTITIES(%s): Entity already exists with id: %s", self._id, entity.id) return False self._storage[entity.id] = entity - _LOG.debug("ENTITIES(%s): Entity added with id: %s", self.id, entity.id) + _LOG.debug("ENTITIES(%s): Entity added with id: %s", self._id, entity.id) return True def remove(self, entity_id: str) -> bool: """Remove entity from storage.""" if entity_id not in self._storage: - _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self._id, entity_id) return True del self._storage[entity_id] - _LOG.debug("ENTITIES(%s): Entity deleted with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity deleted with id: %s", self._id, entity_id) return True def update_attributes(self, entity_id: str, attributes: dict) -> bool: """Update entity attributes.""" if entity_id not in self._storage: - _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self._id, entity_id) return False for key in attributes: self._storage[entity_id].attributes[key] = attributes[key] - self.events.emit( + self._events.emit( Events.ENTITY_ATTRIBUTES_UPDATED, entity_id, self._storage[entity_id].entity_type, attributes, ) - _LOG.debug("ENTITIES(%s): Entity attributes updated with id: %s", self.id, entity_id) + _LOG.debug("ENTITIES(%s): Entity attributes updated with id: %s", self._id, entity_id) return True def get_all(self) -> list[dict[str, any]]: @@ -125,3 +125,17 @@ async def get_states(self) -> list[dict[str, any]]: def clear(self): """Remove all entities from storage.""" self._storage = {} + + ############## + # Properties # + ############## + # TODO redesign event callback: don't expose AsyncIOEventEmitter! The client may not emit events!!! + @property + def events(self) -> AsyncIOEventEmitter: + """Return event emitter.""" + return self._events + + @property + def id(self) -> str: + """Return storage identifier.""" + return self._id From 1641fa79007128869cb0b94c1605ce1db86604fd Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 15:25:44 +0200 Subject: [PATCH 10/25] fix: type info --- ucapi/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 057da3a..4d2b871 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -158,7 +158,7 @@ def __init__( self, identifier: str, name: str | dict, - features: set[Features], + features: list[Features], attributes: dict, device_class: DeviceClasses | None = None, options: dict | None = None, From 3b99bc71f38c67e4fd299971815a4d460ca608da Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 15:26:59 +0200 Subject: [PATCH 11/25] fix: device_info response, type infos --- ucapi/api.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index a379666..16e1563 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -25,8 +25,7 @@ from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf import ucapi.api_definitions as uc - -from .entities import Entities +from ucapi.entities import Entities _LOG = logging.getLogger(__name__) _LOG.setLevel(logging.DEBUG) @@ -102,12 +101,14 @@ async def event_handler(entity_id, entity_type, attributes): socket.gethostbyname(socket.gethostname()) if self._driver_info["driver_url"] is None else self._interface ) + # addr = address if address else None + if self._disable_mdns_publish is False: # Setup zeroconf service info info = AsyncServiceInfo( "_uc-integration._tcp.local.", f"{self._driver_info['driver_id']}._uc-integration._tcp.local.", - addresses=[addr], + addresses=[addr] if addr else None, port=int(self._port), properties={ "name": name, @@ -163,16 +164,25 @@ 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: str, msg_data: dict[str, Any] | None = None) -> None: - await self._send_response(websocket, req_id, "result", msg_data, 200) + async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] | None = None) -> None: + await self._send_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) async def _send_error_result( - self, websocket, req_id: int, status_code=500, msg_data: dict[str, Any] | None = None + self, + websocket, + req_id: int, + status_code: uc.StatusCodes = uc.StatusCodes.SERVER_ERROR, + msg_data: dict[str, Any] | None = None, ) -> None: await self._send_response(websocket, req_id, "result", msg_data, status_code) async def _send_response( - self, websocket, req_id: int, msg: str, msg_data: dict[str, Any] | None, status_code=uc.StatusCodes.OK + self, + websocket, + req_id: int, + msg: str, + msg_data: dict[str, Any] | list[Any] | None, + status_code=uc.StatusCodes.OK, ) -> None: data = { "kind": "resp", @@ -228,7 +238,7 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat if msg == uc.WsMessages.GET_DRIVER_VERSION: await self._send_response(websocket, req_id, uc.WsMsgEvents.DRIVER_VERSION, self.get_driver_version()) elif msg == uc.WsMessages.GET_DEVICE_STATE: - await self._send_response(websocket, req_id, uc.WsMsgEvents.DEVICE_STATE, self.state) + await self._send_response(websocket, req_id, uc.WsMsgEvents.DEVICE_STATE, {"state": self.state}) elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: available_entities = self._available_entities.get_all() await self._send_response( From 3789d6f75468b72da9b8b7e4efcd8cddde52c597 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Oct 2023 18:14:07 +0200 Subject: [PATCH 12/25] fix: improve websocket handling - best effort in broadcast_ws_event - add more type information - add docstrings --- ucapi/api.py | 185 +++++++++++++++++++++++++++++++++++----------- ucapi/entities.py | 14 ++-- ucapi/entity.py | 2 +- 3 files changed, 151 insertions(+), 50 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 16e1563..57419ed 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -78,7 +78,7 @@ async def event_handler(entity_id, entity_type, attributes): "attributes": attributes, } - await self._broadcast_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) + await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: @@ -144,7 +144,7 @@ async def _start_web_socket_server(self) -> None: async def _handle_ws(self, websocket) -> None: try: self._clients.add(websocket) - _LOG.info("WS: Client added") + _LOG.info("WS: Client added: %s", websocket.remote_address) # authenticate on connection await self._authenticate(websocket, True) @@ -154,18 +154,31 @@ async def _handle_ws(self, websocket) -> None: await self._process_ws_message(websocket, message) except ConnectionClosedOK: - _LOG.info("WS: Connection Closed") + _LOG.info("WS: Connection closed") - except websockets.exceptions.ConnectionClosedError: - _LOG.info("WS: Connection Closed") + except websockets.exceptions.ConnectionClosedError as e: + _LOG.info("WS: Connection closed with error %d: %s", e.code, e.reason) + + except websockets.exceptions.WebSocketException as e: + _LOG.error("WS: Connection closed due to processing error: %s", e) finally: self._clients.remove(websocket) _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] | None = None) -> None: - await self._send_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) + 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. + + :param websocket: client connection + :param req_id: request message identifier + :param msg_data: message data payload + + Raises: + websockets.ConnectionClosed: When the connection is closed. + """ + await self._send_ws_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) async def _send_error_result( self, @@ -174,16 +187,39 @@ async def _send_error_result( status_code: uc.StatusCodes = uc.StatusCodes.SERVER_ERROR, msg_data: dict[str, Any] | None = None, ) -> None: - await self._send_response(websocket, req_id, "result", msg_data, status_code) + """ + Send a WebSocket error response message. - async def _send_response( + :param websocket: client connection + :param req_id: request message identifier + :param status_code: status code + :param msg_data: message data payload + + Raises: + websockets.ConnectionClosed: When the connection is closed. + """ + await self._send_ws_response(websocket, req_id, "result", msg_data, status_code) + + async def _send_ws_response( self, websocket, req_id: int, msg: str, - msg_data: dict[str, Any] | list[Any] | None, - status_code=uc.StatusCodes.OK, + msg_data: dict[str, Any] | list | None, + status_code: uc.StatusCodes = uc.StatusCodes.OK, ) -> None: + """ + Send a WebSocket response message. + + :param websocket: client connection + :param req_id: request message identifier + :param msg: message name + :param msg_data: message data payload + :param status_code: status code + + Raises: + websockets.ConnectionClosed: When the connection is closed. + """ data = { "kind": "resp", "req_id": req_id, @@ -194,31 +230,54 @@ async def _send_response( if websocket in self._clients: data_dump = json.dumps(data) - _LOG.debug("->: %s", data_dump) + _LOG.debug("[%s] ->: %s", websocket.remote_address, data_dump) await websocket.send(data_dump) else: _LOG.error("Error sending response: connection no longer established") - async def _broadcast_event(self, msg: str, msg_data: dict[str, Any], category: str) -> 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. + + :param msg: event message name + :param msg_data: message data payload + :param category: event category + """ data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} for websocket in self._clients: data_dump = json.dumps(data) - _LOG.debug("->: %s", data_dump) - await websocket.send(data_dump) + _LOG.debug("[%s] ->: %s", websocket.remote_address, data_dump) + try: + await websocket.send(data_dump) + except websockets.exceptions.WebSocketException: + pass + + 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. + + :param websocket: client connection + :param msg: event message name + :param msg_data: message data payload + :param category: event category - async def _send_event(self, websocket, msg: str, msg_data: dict[str, Any], category: str) -> None: + Raises: + websockets.ConnectionClosed: When the connection is closed. + """ data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} if websocket in self._clients: data_dump = json.dumps(data) - _LOG.debug("->: %s", data_dump) + _LOG.debug("[%s] ->: %s", websocket.remote_address, data_dump) await websocket.send(data_dump) else: _LOG.error("Error sending event: connection no longer established") async def _process_ws_message(self, websocket, message) -> None: - _LOG.debug("<-: %s", message) + _LOG.debug("[%s] <-: %s", websocket.remote_address, message) data = json.loads(message) kind = data["kind"] @@ -236,12 +295,12 @@ async def _process_ws_message(self, websocket, message) -> 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_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_response(websocket, req_id, uc.WsMsgEvents.DEVICE_STATE, {"state": self.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_response( + await self._send_ws_response( websocket, req_id, uc.WsMsgEvents.AVAILABLE_ENTITIES, @@ -249,7 +308,7 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat ) elif msg == uc.WsMessages.GET_ENTITY_STATES: entity_states = await self._configured_entities.get_states() - await self._send_response( + await self._send_ws_response( websocket, req_id, uc.WsMsgEvents.ENTITY_STATES, @@ -264,7 +323,7 @@ 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_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: await self._setup_driver(websocket, req_id, msg_data) elif msg == uc.WsMessages.SET_DRIVER_USER_DATA: @@ -283,7 +342,7 @@ def _handle_ws_event_msg(self, msg: str, _msg_data: dict[str, Any] | None) -> No self._events.emit(uc.Events.SETUP_DRIVER_ABORT) async def _authenticate(self, websocket, success: bool) -> None: - await self._send_response( + await self._send_ws_response( websocket, 0, uc.WsMessages.AUTHENTICATION, @@ -301,12 +360,14 @@ def get_driver_version(self) -> dict[str, dict[str, Any]]: }, } - # TODO use a property setter? async def set_device_state(self, state: uc.DeviceStates) -> None: - """Set new state.""" - self._state = state + """Set new device state and notify all connected clients.""" + if self._state != state: + self._state = state - await self._broadcast_event(uc.WsMsgEvents.DEVICE_STATE, {"state": self.state}, uc.EventCategory.DEVICE) + await self._broadcast_ws_event( + uc.WsMsgEvents.DEVICE_STATE, {"state": self.device_state}, uc.EventCategory.DEVICE + ) async def _subscribe_events(self, msg_data: dict[str, Any] | None) -> None: if msg_data is None: @@ -367,18 +428,54 @@ async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str else: _LOG.warning("Unsupported set_driver_user_data payload received") - async def acknowledge_command(self, websocket, req_id: int, status_code=uc.StatusCodes.OK) -> None: - """Acknowledge a command from Remote Two.""" - await self._send_response(websocket, req_id, "result", {}, status_code) + async def acknowledge_command( + self, websocket, req_id: int, status_code: uc.StatusCodes = uc.StatusCodes.OK + ) -> None: + """ + Acknowledge a command from Remote Two. + + :param websocket: client connection + :param req_id: request message identifier to acknowledge + :param status_code: status code + + Raises: + websockets.ConnectionClosed: When the connection is closed. + """ + await self._send_ws_response(websocket, req_id, "result", {}, status_code) async def driver_setup_progress(self, websocket) -> None: - """Send a driver setup progress event to Remote Two.""" + """ + Send a driver setup progress event to Remote Two. + + :param websocket: client connection + + Raises: + websockets.ConnectionClosed: When the connection is closed. + """ data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_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, websocket, title, msg1=None, image=None, msg2=None) -> None: - """Request a user confirmation during the driver setup process.""" + async def request_driver_setup_user_confirmation( + self, + websocket, + title: str | dict[str, str], + msg1: str | dict[str, str] | None = None, + image: str | None = None, + msg2: str | dict[str, str] | None = None, + ) -> None: + """ + Request a user confirmation during the driver setup process. + + :param websocket: client connection + :param title: page title + :param msg1: optional header message + :param image: optional image between header and footer + :param msg2: optional footer message + + Raises: + websockets.ConnectionClosed: When the connection is closed. + """ data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", @@ -392,9 +489,9 @@ async def request_driver_setup_user_confirmation(self, websocket, title, msg1=No }, } - await self._send_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, settings: dict[str, Any]) -> None: + async def request_driver_setup_user_input(self, websocket, title, settings: dict[str, Any] | list) -> None: """Request a user input during the driver setup process.""" data = { "event_type": "SETUP", @@ -402,19 +499,19 @@ async def request_driver_setup_user_input(self, websocket, title, settings: dict "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, } - await self._send_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_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_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) ############## # Properties # @@ -426,8 +523,12 @@ def events(self) -> AsyncIOEventEmitter: return self._events @property - def state(self) -> str: - """Return driver state.""" + def device_state(self) -> uc.DeviceStates: + """ + Return device state. + + Use set_device_state to update the state and notify all clients. + """ return self._state @property diff --git a/ucapi/entities.py b/ucapi/entities.py index 935cdce..e461ab4 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -39,7 +39,7 @@ def contains(self, entity_id: str) -> bool: def get(self, entity_id: str) -> Entity | None: """Retrieve entity with given identifier.""" if entity_id not in self._storage: - _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self._id, entity_id) + _LOG.debug("[%s]: entity not found with id: %s", self._id, entity_id) return None return self._storage[entity_id] @@ -47,27 +47,27 @@ def get(self, entity_id: str) -> Entity | None: def add(self, entity: Entity) -> bool: """Add entity to storage.""" if entity.id in self._storage: - _LOG.debug("ENTITIES(%s): Entity already exists with id: %s", self._id, entity.id) + _LOG.debug("[%s]: entity already exists with id: %s", self._id, entity.id) return False self._storage[entity.id] = entity - _LOG.debug("ENTITIES(%s): Entity added with id: %s", self._id, entity.id) + _LOG.debug("[%s]: entity added with id: %s", self._id, entity.id) return True def remove(self, entity_id: str) -> bool: """Remove entity from storage.""" if entity_id not in self._storage: - _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self._id, entity_id) + _LOG.debug("[%s]: entity not found with id: %s", self._id, entity_id) return True del self._storage[entity_id] - _LOG.debug("ENTITIES(%s): Entity deleted with id: %s", self._id, entity_id) + _LOG.debug("[%s]: entity deleted with id: %s", self._id, entity_id) return True def update_attributes(self, entity_id: str, attributes: dict) -> bool: """Update entity attributes.""" if entity_id not in self._storage: - _LOG.debug("ENTITIES(%s): Entity does not exists with id: %s", self._id, entity_id) + _LOG.debug("[%s]: entity not found with id: %s", self._id, entity_id) return False for key in attributes: @@ -80,7 +80,7 @@ def update_attributes(self, entity_id: str, attributes: dict) -> bool: attributes, ) - _LOG.debug("ENTITIES(%s): Entity attributes updated with id: %s", self._id, entity_id) + _LOG.debug("[%s]: entity '%s' attributes updated", self._id, entity_id) return True def get_all(self) -> list[dict[str, any]]: diff --git a/ucapi/entity.py b/ucapi/entity.py index ec9255d..e53a6eb 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -65,4 +65,4 @@ def __init__( self.options = options self.area = area - _LOG.debug("%s entity created with id: %s", self.entity_type, self.id) + _LOG.debug("Created %s entity with id: %s", self.entity_type.value, self.id) From e8170e3a7598623c5aca25ad7c2a7fc5a9c409d7 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sat, 28 Oct 2023 14:05:24 +0200 Subject: [PATCH 13/25] fix: add Entity base class to __init__ imports --- ucapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ucapi/__init__.py b/ucapi/__init__.py index 5dfdd66..b4c0d16 100644 --- a/ucapi/__init__.py +++ b/ucapi/__init__.py @@ -11,7 +11,7 @@ import logging # isort:skip from .api_definitions import DeviceStates, Events, StatusCodes # isort:skip # noqa: F401 -from .entity import EntityTypes # isort:skip # noqa: F401 +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 From 6dc1e1d20b7a236a3276c4e95698234b513a40ce Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sat, 28 Oct 2023 14:09:38 +0200 Subject: [PATCH 14/25] add and fix typing info --- ucapi/button.py | 2 +- ucapi/climate.py | 7 ++++--- ucapi/cover.py | 7 ++++--- ucapi/entities.py | 24 ++++++++++++------------ ucapi/entity.py | 7 ++++--- ucapi/light.py | 7 ++++--- ucapi/media_player.py | 7 ++++--- ucapi/sensor.py | 7 ++++--- ucapi/switch.py | 7 ++++--- 9 files changed, 41 insertions(+), 34 deletions(-) diff --git a/ucapi/button.py b/ucapi/button.py index f36162d..734374c 100644 --- a/ucapi/button.py +++ b/ucapi/button.py @@ -37,7 +37,7 @@ class Button(Entity): for more information. """ - def __init__(self, identifier: str, name: str | dict, area: str | None = None): + def __init__(self, identifier: str, name: str | dict[str, str], area: str | None = None): """ Create button-entity instance. diff --git a/ucapi/climate.py b/ucapi/climate.py index 9633fe4..4da2762 100644 --- a/ucapi/climate.py +++ b/ucapi/climate.py @@ -7,6 +7,7 @@ """ from enum import Enum +from typing import Any from ucapi.entity import Entity, EntityTypes @@ -83,11 +84,11 @@ class Climate(Entity): def __init__( self, identifier: str, - name: str | dict, + name: str | dict[str, str], features: list[Features], - attributes: dict, + attributes: dict[str, Any], device_class: str | None = None, - options: dict | None = None, + options: dict[str, Any] | None = None, area: str | None = None, ): """ diff --git a/ucapi/cover.py b/ucapi/cover.py index 19325b7..ee772ff 100644 --- a/ucapi/cover.py +++ b/ucapi/cover.py @@ -7,6 +7,7 @@ """ from enum import Enum +from typing import Any from ucapi.entity import Entity, EntityTypes @@ -82,11 +83,11 @@ class Cover(Entity): def __init__( self, identifier: str, - name: str | dict, + name: str | dict[str, str], features: list[Features], - attributes: dict, + attributes: dict[str, Any], device_class: DeviceClasses | None = None, - options: dict | None = None, + options: dict[str, Any] | None = None, area: str | None = None, ): """ diff --git a/ucapi/entities.py b/ucapi/entities.py index e461ab4..31e56d4 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -7,6 +7,7 @@ import logging from asyncio import AbstractEventLoop +from typing import Any from pyee import AsyncIOEventEmitter @@ -27,10 +28,9 @@ def __init__(self, identifier: str, loop: AbstractEventLoop): :param identifier: storage identifier. :param loop: event loop """ - self._id = identifier - self._loop = loop + self._id: str = identifier self._storage = {} - self._events = AsyncIOEventEmitter(self._loop) + self._events = AsyncIOEventEmitter(loop) def contains(self, entity_id: str) -> bool: """Check if storage contains an entity with given identifier.""" @@ -39,7 +39,7 @@ def contains(self, entity_id: str) -> bool: def get(self, entity_id: str) -> Entity | None: """Retrieve entity with given identifier.""" if entity_id not in self._storage: - _LOG.debug("[%s]: entity not found with id: %s", self._id, entity_id) + _LOG.debug("[%s]: entity not found with id: '%s'", self._id, entity_id) return None return self._storage[entity_id] @@ -47,27 +47,27 @@ def get(self, entity_id: str) -> Entity | None: def add(self, entity: Entity) -> bool: """Add entity to storage.""" if entity.id in self._storage: - _LOG.debug("[%s]: entity already exists with id: %s", self._id, entity.id) + _LOG.debug("[%s] entity already exists with id: '%s'", self._id, entity.id) return False self._storage[entity.id] = entity - _LOG.debug("[%s]: entity added with id: %s", self._id, entity.id) + _LOG.debug("[%s] entity added with id: '%s'", self._id, entity.id) return True def remove(self, entity_id: str) -> bool: """Remove entity from storage.""" if entity_id not in self._storage: - _LOG.debug("[%s]: entity not found with id: %s", self._id, entity_id) + _LOG.debug("[%s] cannot remove entity '%s': not found", self._id, entity_id) return True del self._storage[entity_id] - _LOG.debug("[%s]: entity deleted with id: %s", self._id, entity_id) + _LOG.debug("[%s] entity deleted with id: %s", self._id, entity_id) return True - def update_attributes(self, entity_id: str, attributes: dict) -> 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]: entity not found with id: %s", self._id, entity_id) + _LOG.debug("[%s] cannot update entity attributes '%s': not found", self._id, entity_id) return False for key in attributes: @@ -83,7 +83,7 @@ def update_attributes(self, entity_id: str, attributes: dict) -> bool: _LOG.debug("[%s]: entity '%s' attributes updated", self._id, entity_id) return True - def get_all(self) -> list[dict[str, any]]: + def get_all(self) -> list[dict[str, Any]]: """ Get all entity information in storage. @@ -106,7 +106,7 @@ def get_all(self) -> list[dict[str, any]]: return entities - async def get_states(self) -> list[dict[str, any]]: + async def get_states(self) -> list[dict[str, Any]]: """Get all entity information with entity_id, entity_type, device_id, attributes.""" entities = [] diff --git a/ucapi/entity.py b/ucapi/entity.py index e53a6eb..5ee2c7b 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -7,6 +7,7 @@ import logging from enum import Enum +from typing import Any _LOG = logging.getLogger(__name__) _LOG.setLevel(logging.DEBUG) @@ -35,12 +36,12 @@ class Entity: def __init__( self, identifier: str, - name: str | dict, + name: str | dict[str, str], entity_type: EntityTypes, features: list[str], - attributes: dict, + attributes: dict[str, Any], device_class: str | None, - options: dict | None, + options: dict[str, Any] | None, area: str | None = None, ): """ diff --git a/ucapi/light.py b/ucapi/light.py index c0cf06c..9d1e2d6 100644 --- a/ucapi/light.py +++ b/ucapi/light.py @@ -7,6 +7,7 @@ """ from enum import Enum +from typing import Any from ucapi.entity import Entity, EntityTypes @@ -69,11 +70,11 @@ class Light(Entity): def __init__( self, identifier: str, - name: str | dict, + name: str | dict[str, str], features: list[Features], - attributes: dict, + attributes: dict[str, Any], device_class: DeviceClasses | None = None, - options: dict | None = None, + options: dict[str, Any] | None = None, area: str | None = None, ): """ diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 4d2b871..9a345cd 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -7,6 +7,7 @@ """ from enum import Enum +from typing import Any from ucapi.entity import Entity, EntityTypes @@ -157,11 +158,11 @@ class MediaPlayer(Entity): def __init__( self, identifier: str, - name: str | dict, + name: str | dict[str, str], features: list[Features], - attributes: dict, + attributes: dict[str, Any], device_class: DeviceClasses | None = None, - options: dict | None = None, + options: dict[str, Any] | None = None, area: str | None = None, ): """ diff --git a/ucapi/sensor.py b/ucapi/sensor.py index 2bb87f6..c098175 100644 --- a/ucapi/sensor.py +++ b/ucapi/sensor.py @@ -7,6 +7,7 @@ """ from enum import Enum +from typing import Any from ucapi.entity import Entity, EntityTypes @@ -69,11 +70,11 @@ class Sensor(Entity): def __init__( self, identifier: str, - name: str | dict, + name: str | dict[str, str], features: list[Features], - attributes: dict, + attributes: dict[str, Any], device_class: DeviceClasses | None = None, - options: dict | None = None, + options: dict[str, Any] | None = None, area: str | None = None, ): """ diff --git a/ucapi/switch.py b/ucapi/switch.py index 0fe96c8..373a5ca 100644 --- a/ucapi/switch.py +++ b/ucapi/switch.py @@ -6,6 +6,7 @@ """ from enum import Enum +from typing import Any from ucapi.entity import Entity, EntityTypes @@ -64,11 +65,11 @@ class Switch(Entity): def __init__( self, identifier: str, - name: str | dict, + name: str | dict[str, str], features: list[Features], - attributes: dict, + attributes: dict[str, Any], device_class: DeviceClasses | None = None, - options: dict | None = None, + options: dict[str, Any] | None = None, area: str | None = None, ): """ From 4faa1327828f8deb6296ff4072ffd91b7c7b00cc Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sat, 28 Oct 2023 14:56:33 +0200 Subject: [PATCH 15/25] shorten some log messages --- ucapi/entities.py | 8 ++++---- ucapi/entity.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ucapi/entities.py b/ucapi/entities.py index 31e56d4..ba6d555 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -39,7 +39,7 @@ def contains(self, entity_id: str) -> bool: def get(self, entity_id: str) -> Entity | None: """Retrieve entity with given identifier.""" if entity_id not in self._storage: - _LOG.debug("[%s]: entity not found with id: '%s'", self._id, entity_id) + _LOG.debug("[%s]: entity not found: '%s'", self._id, entity_id) return None return self._storage[entity_id] @@ -47,11 +47,11 @@ def get(self, entity_id: str) -> Entity | None: def add(self, entity: Entity) -> bool: """Add entity to storage.""" if entity.id in self._storage: - _LOG.debug("[%s] entity already exists with id: '%s'", self._id, entity.id) + _LOG.debug("[%s] entity already exists: '%s'", self._id, entity.id) return False self._storage[entity.id] = entity - _LOG.debug("[%s] entity added with id: '%s'", self._id, entity.id) + _LOG.debug("[%s] entity added: '%s'", self._id, entity.id) return True def remove(self, entity_id: str) -> bool: @@ -61,7 +61,7 @@ def remove(self, entity_id: str) -> bool: return True del self._storage[entity_id] - _LOG.debug("[%s] entity deleted with id: %s", self._id, entity_id) + _LOG.debug("[%s] entity deleted: %s", self._id, entity_id) return True def update_attributes(self, entity_id: str, attributes: dict[str, Any]) -> bool: diff --git a/ucapi/entity.py b/ucapi/entity.py index 5ee2c7b..ed211ff 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -66,4 +66,4 @@ def __init__( self.options = options self.area = area - _LOG.debug("Created %s entity with id: %s", self.entity_type.value, self.id) + _LOG.debug("Created %s entity: %s", self.entity_type.value, self.id) From f3b7a89d80c182d0e1157973d804704cc24b9958 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sat, 28 Oct 2023 17:43:42 +0200 Subject: [PATCH 16/25] refactor: don't expose AsyncIOEventEmitter for event callbacks The client may only listen to events, but not take control over the event emitter or know about the internals. --- ucapi/api.py | 56 +++++++++++++++++++++++++++++++++-------------- ucapi/entities.py | 37 +++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 57419ed..709fd19 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -11,10 +11,10 @@ import os import socket from asyncio import AbstractEventLoop -from typing import Any +from typing import Any, Callable import websockets -from pyee import AsyncIOEventEmitter +from pyee.asyncio import AsyncIOEventEmitter # workaround for pylint error: E0611: No name 'ConnectionClosedOK' in module 'websockets' (no-name-in-module) from websockets.exceptions import ConnectionClosedOK @@ -70,15 +70,7 @@ async def init(self, driver_path: str): self._driver_path = driver_path self._port = self._driver_info["port"] if "port" in self._driver_info else self._port - @self._configured_entities.events.on(uc.Events.ENTITY_ATTRIBUTES_UPDATED) - async def event_handler(entity_id, entity_type, attributes): - data = { - "entity_id": entity_id, - "entity_type": entity_type, - "attributes": attributes, - } - - await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) + 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: @@ -128,6 +120,15 @@ async def event_handler(entity_id, entity_type, attributes): self._driver_info["driver_url"], ) + async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes): + data = { + "entity_id": entity_id, + "entity_type": entity_type, + "attributes": attributes, + } + + await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) + def _get_driver_url(self, driver_url: str | None, port: int | str) -> str | None: if driver_url is not None: if driver_url.startswith("ws://") or driver_url.startswith("wss://"): @@ -513,14 +514,37 @@ async def driver_setup_error(self, websocket, error="OTHER") -> None: 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: + """ + Register a callback handler for the given event. + + :param event: the event + :param f: callback handler + """ + self._events.add_listener(event, f) + + def remove_listener(self, event: uc.Events, f: Callable) -> None: + """ + Remove the callback handler for the given event. + + :param event: the event + :param f: callback handler + """ + self._events.remove_listener(event, f) + + def remove_all_listeners(self, event: uc.Events | None) -> None: + """ + Remove all listeners attached to ``event``. + + If ``event`` is ``None``, remove all listeners on all events. + + :param event: the event + """ + self._events.remove_all_listeners(event) + ############## # Properties # ############## - # TODO redesign event callback: don't expose AsyncIOEventEmitter! The client may not emit events!!! - @property - def events(self) -> AsyncIOEventEmitter: - """Return event emitter.""" - return self._events @property def device_state(self) -> uc.DeviceStates: diff --git a/ucapi/entities.py b/ucapi/entities.py index ba6d555..96eca9c 100644 --- a/ucapi/entities.py +++ b/ucapi/entities.py @@ -7,9 +7,9 @@ import logging from asyncio import AbstractEventLoop -from typing import Any +from typing import Any, Callable -from pyee import AsyncIOEventEmitter +from pyee.asyncio import AsyncIOEventEmitter from ucapi.api_definitions import Events from ucapi.entity import Entity @@ -126,14 +126,37 @@ def clear(self): """Remove all entities from storage.""" self._storage = {} + def add_listener(self, event: Events, f: Callable) -> None: + """ + Register a callback handler for the given event. + + :param event: the event + :param f: callback handler + """ + self._events.add_listener(event, f) + + def remove_listener(self, event: Events, f: Callable) -> None: + """ + Remove the callback handler for the given event. + + :param event: the event + :param f: callback handler + """ + self._events.remove_listener(event, f) + + def remove_all_listeners(self, event: Events | None) -> None: + """ + Remove all listeners attached to ``event``. + + If ``event`` is ``None``, remove all listeners on all events. + + :param event: the event + """ + self._events.remove_all_listeners(event) + ############## # Properties # ############## - # TODO redesign event callback: don't expose AsyncIOEventEmitter! The client may not emit events!!! - @property - def events(self) -> AsyncIOEventEmitter: - """Return event emitter.""" - return self._events @property def id(self) -> str: From 5436d3536c2c3971661757730da14a2e6ae7c7df Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sun, 29 Oct 2023 01:33:47 +0200 Subject: [PATCH 17/25] refactor: entity command handler Add a command handler on the Entity. This will simplify the client command handling significantly: - configured entity is passed as argument - basic error checking is done in the library - websocket stays hidden - command acknowledgment is implemented in the library --- ucapi/api.py | 41 +++++++++++++++++++++++++++++----------- ucapi/api_definitions.py | 8 +++++++- ucapi/button.py | 11 ++++++++++- ucapi/climate.py | 12 ++++-------- ucapi/cover.py | 4 ++++ ucapi/entity.py | 22 +++++++++++++++++++++ ucapi/light.py | 12 ++++-------- ucapi/media_player.py | 12 ++++-------- ucapi/switch.py | 4 ++++ 9 files changed, 89 insertions(+), 37 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 709fd19..c8ea2fd 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -402,18 +402,26 @@ 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: - if msg_data is 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) return - self._events.emit( - uc.Events.ENTITY_COMMAND, - websocket, - req_id, - msg_data["entity_id"], - msg_data["entity_type"], - msg_data["cmd_id"], - msg_data["params"] if "params" in msg_data else None, - ) + + 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) + 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) + 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) + 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: @@ -492,7 +500,9 @@ async def request_driver_setup_user_confirmation( 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, settings: dict[str, Any] | list) -> None: + async def request_driver_setup_user_input( + self, websocket, title: str | dict[str, str], settings: dict[str, Any] | list + ) -> None: """Request a user input during the driver setup process.""" data = { "event_type": "SETUP", @@ -523,6 +533,15 @@ 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 on(f: Callable) -> Callable: + self._events.add_listener(event, f) + return f + + return on + def remove_listener(self, event: uc.Events, f: Callable) -> None: """ Remove the callback handler for the given event. diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index e8fabc4..70b9f7e 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -6,6 +6,7 @@ """ from enum import Enum, IntEnum +from typing import Any, Awaitable, Callable, TypeAlias class DeviceStates(str, Enum): @@ -24,7 +25,10 @@ class StatusCodes(IntEnum): BAD_REQUEST = 400 UNAUTHORIZED = 401 NOT_FOUND = 404 + TIMEOUT = 408 + CONFLICT = 409 SERVER_ERROR = 500 + NOT_IMPLEMENTED = 501 SERVICE_UNAVAILABLE = 503 @@ -66,7 +70,6 @@ class WsMsgEvents(str, Enum): class Events(str, Enum): """Internal library events.""" - ENTITY_COMMAND = "entity_command" ENTITY_ATTRIBUTES_UPDATED = "entity_attributes_updated" SUBSCRIBE_ENTITIES = "subscribe_entities" UNSUBSCRIBE_ENTITIES = "unsubscribe_entities" @@ -86,3 +89,6 @@ class EventCategory(str, Enum): DEVICE = "DEVICE" ENTITY = "ENTITY" + + +CommandHandler: TypeAlias = Callable[[Any, str, dict[str, Any] | None], Awaitable[StatusCodes]] diff --git a/ucapi/button.py b/ucapi/button.py index 734374c..6289877 100644 --- a/ucapi/button.py +++ b/ucapi/button.py @@ -7,6 +7,7 @@ from enum import Enum +from ucapi.api_definitions import CommandHandler from ucapi.entity import Entity, EntityTypes @@ -37,13 +38,20 @@ class Button(Entity): for more information. """ - def __init__(self, identifier: str, name: str | dict[str, str], area: str | None = None): + def __init__( + self, + identifier: str, + name: str | dict[str, str], + area: str | None = None, + cmd_handler: CommandHandler = None, + ): """ Create button-entity instance. :param identifier: entity identifier :param name: friendly name, either a string or a language dictionary :param area: optional area name + :param cmd_handler: handler for entity commands """ super().__init__( identifier, @@ -54,4 +62,5 @@ def __init__(self, identifier: str, name: str | dict[str, str], area: str | None None, None, area, + cmd_handler, ) diff --git a/ucapi/climate.py b/ucapi/climate.py index 4da2762..6574881 100644 --- a/ucapi/climate.py +++ b/ucapi/climate.py @@ -9,6 +9,7 @@ from enum import Enum from typing import Any +from ucapi.api_definitions import CommandHandler from ucapi.entity import Entity, EntityTypes @@ -90,6 +91,7 @@ def __init__( device_class: str | None = None, options: dict[str, Any] | None = None, area: str | None = None, + cmd_handler: CommandHandler = None, ): """ Create a climate-entity instance. @@ -101,14 +103,8 @@ def __init__( :param device_class: optional climate device class :param options: options :param area: optional area + :param cmd_handler: handler for entity commands """ super().__init__( - identifier, - name, - EntityTypes.CLIMATE, - features, - attributes, - device_class, - options, - area, + identifier, name, EntityTypes.CLIMATE, features, attributes, device_class, options, area, cmd_handler ) diff --git a/ucapi/cover.py b/ucapi/cover.py index ee772ff..b41578a 100644 --- a/ucapi/cover.py +++ b/ucapi/cover.py @@ -9,6 +9,7 @@ from enum import Enum from typing import Any +from ucapi.api_definitions import CommandHandler from ucapi.entity import Entity, EntityTypes @@ -89,6 +90,7 @@ def __init__( device_class: DeviceClasses | None = None, options: dict[str, Any] | None = None, area: str | None = None, + cmd_handler: CommandHandler = None, ): """ Create cover-entity instance. @@ -100,6 +102,7 @@ def __init__( :param device_class: optional cover device class :param options: options :param area: optional area + :param cmd_handler: handler for entity commands """ super().__init__( identifier, @@ -110,4 +113,5 @@ def __init__( device_class, options, area, + cmd_handler, ) diff --git a/ucapi/entity.py b/ucapi/entity.py index ed211ff..eb45292 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -9,6 +9,8 @@ from enum import Enum from typing import Any +from ucapi.api_definitions import CommandHandler, StatusCodes + _LOG = logging.getLogger(__name__) _LOG.setLevel(logging.DEBUG) @@ -43,6 +45,7 @@ def __init__( device_class: str | None, options: dict[str, Any] | None, area: str | None = None, + cmd_handler: CommandHandler = None, ): """ Initialize entity. @@ -65,5 +68,24 @@ def __init__( self.device_class = device_class self.options = options self.area = area + self.cmd_handler = cmd_handler _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: + """ + Execute entity command with the installed command handler. + + Returns NOT_IMPLEMENTED if no command handler is installed. + + :param cmd_id: the command + :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) + + _LOG.warning( + "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 9d1e2d6..b7f56c1 100644 --- a/ucapi/light.py +++ b/ucapi/light.py @@ -9,6 +9,7 @@ from enum import Enum from typing import Any +from ucapi.api_definitions import CommandHandler from ucapi.entity import Entity, EntityTypes @@ -76,6 +77,7 @@ def __init__( device_class: DeviceClasses | None = None, options: dict[str, Any] | None = None, area: str | None = None, + cmd_handler: CommandHandler = None, ): """ Create light-entity instance. @@ -87,14 +89,8 @@ def __init__( :param device_class: optional light device class :param options: options :param area: optional area + :param cmd_handler: handler for entity commands """ super().__init__( - identifier, - name, - EntityTypes.LIGHT, - features, - attributes, - device_class, - options, - area, + 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 9a345cd..06415e7 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -9,6 +9,7 @@ from enum import Enum from typing import Any +from ucapi.api_definitions import CommandHandler from ucapi.entity import Entity, EntityTypes @@ -164,6 +165,7 @@ def __init__( device_class: DeviceClasses | None = None, options: dict[str, Any] | None = None, area: str | None = None, + cmd_handler: CommandHandler = None, ): """ Create media-player entity instance. @@ -175,14 +177,8 @@ def __init__( :param device_class: optional media-player device class :param options: options :param area: optional area + :param cmd_handler: handler for entity commands """ super().__init__( - identifier, - name, - EntityTypes.MEDIA_PLAYER, - features, - attributes, - device_class, - options, - area, + identifier, name, EntityTypes.MEDIA_PLAYER, features, attributes, device_class, options, area, cmd_handler ) diff --git a/ucapi/switch.py b/ucapi/switch.py index 373a5ca..8574767 100644 --- a/ucapi/switch.py +++ b/ucapi/switch.py @@ -8,6 +8,7 @@ from enum import Enum from typing import Any +from ucapi.api_definitions import CommandHandler from ucapi.entity import Entity, EntityTypes @@ -71,6 +72,7 @@ def __init__( device_class: DeviceClasses | None = None, options: dict[str, Any] | None = None, area: str | None = None, + cmd_handler: CommandHandler = None, ): """ Create switch-entity instance. @@ -82,6 +84,7 @@ def __init__( :param device_class: optional switch device class :param options: options :param area: optional area + :param cmd_handler: handler for entity commands """ super().__init__( identifier, @@ -92,4 +95,5 @@ def __init__( device_class, options, area, + cmd_handler, ) From 48947e5c5a60fd9e0b908a25c1feaabd0355bd74 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sun, 29 Oct 2023 18:12:16 +0100 Subject: [PATCH 18/25] 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 "" From 659ba36ec81fe76576ea638d890bb8d81a85d969 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sun, 29 Oct 2023 23:47:31 +0100 Subject: [PATCH 19/25] fix: mDNS service publishing Publish service with local hostname. Cleanup interface and port handling. Fixes #2 Fixes #3 --- ucapi/api.py | 110 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 70 insertions(+), 40 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 55eed66..6219905 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -49,11 +49,6 @@ def __init__(self, loop: AbstractEventLoop): self._server_task = None self._clients = set() - self._interface: str | None = os.getenv("UC_INTEGRATION_INTERFACE") - self._port: int | str | None = os.getenv("UC_INTEGRATION_HTTP_PORT") - self._https_enabled: bool = os.getenv("UC_INTEGRATION_HTTPS_ENABLED", "false").lower() in ("true", "1") - self._disable_mdns_publish: bool = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") - self._config_dir_path: str | None = os.getenv("UC_CONFIG_HOME") self._available_entities = Entities("available", self._loop) @@ -70,7 +65,6 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N :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) @@ -79,48 +73,48 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N with open(self._driver_path, "r", encoding="utf-8") as file: self._driver_info = json.load(file) - # Set driver URL - # TODO verify _get_driver_url: logic might not be correct, - # move all parameter logic inside method to better understand what this does - self._driver_info["driver_url"] = self._get_driver_url( - self._driver_info["driver_url"] if "driver_url" in self._driver_info else self._interface, self._port + # 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 ) - # Set driver name - name = _get_default_language_string(self._driver_info["name"], "Unknown driver") - # TODO there seems to be missing something with `url` - # url = self._interface - - # TODO: add support for secured WS - addr = ( - socket.gethostbyname(socket.gethostname()) if self._driver_info["driver_url"] is None else self._interface - ) + _adjust_driver_url(self._driver_info, port) - # addr = address if address else None + disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") - if self._disable_mdns_publish is False: + 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") + + _LOG.debug("Publishing driver: name=%s, host=%s:%d", name, hostname, port) + info = AsyncServiceInfo( "_uc-integration._tcp.local.", - f"{self._driver_info['driver_id']}._uc-integration._tcp.local.", - addresses=[addr] if addr else None, - port=int(self._port), + name, + addresses=[interface] if interface else None, + port=port, properties={ - "name": name, + "name": driver_name, "ver": self._driver_info["version"], "developer": self._driver_info["developer"]["name"], }, + server=hostname, ) zeroconf = AsyncZeroconf(ip_version=IPVersion.V4Only) await zeroconf.async_register_service(info) - self._server_task = self._loop.create_task(self._start_web_socket_server()) + 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)) _LOG.info( - "Driver is up: %s, version: %s, listening on: %s", + "Driver is up: %s, version: %s, listening on: %s:%d", self._driver_info["driver_id"], self._driver_info["version"], - self._driver_info["driver_url"], + host, + port, ) async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes): @@ -132,17 +126,8 @@ async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) - def _get_driver_url(self, driver_url: str | None, port: int | str) -> str | None: - if driver_url is not None: - if driver_url.startswith("ws://") or driver_url.startswith("wss://"): - return driver_url - - return "ws://" + self._interface + ":" + port - - return None - - async def _start_web_socket_server(self) -> None: - async with serve(self._handle_ws, self._interface, int(self._port)): + async def _start_web_socket_server(self, host: str, port: int) -> None: + async with serve(self._handle_ws, host, port): await asyncio.Future() async def _handle_ws(self, websocket) -> None: @@ -682,3 +667,48 @@ def _get_default_language_string(text: str | dict[str, str] | None, default_text return text[key] return default_text + + +def _adjust_driver_url(driver_info: dict[str, Any], port: int) -> str | None: + """ + Adjust the driver_url field in the driver metadata. + + By default, the ``driver_url`` is not set in the metadata file. It is used + to overwrite the published URL by mDNS. UCR2 uses the driver URL from mDNS + if ``driver_url`` in the metadata file is missing. + + Adjustment: + - do nothing if driver url isn't set + - leave driver url as-is if it is starting with ``ws://`` or ``wss://`` + - otherwise dynamically set from determined os hostname and port setting + + :param driver_info: driver metadata + :param port: WebSocket server port + :return: adjusted driver url or None + """ + driver_url = driver_info["driver_url"] if "driver_url" in driver_info else None + + if driver_url is None: + return None + + if driver_url.startswith("ws://") or driver_url.startswith("wss://"): + return driver_url + + host = socket.gethostname() + driver_info["driver_url"] = f"ws://{host}:{port}" + return driver_info["driver_url"] + + +def local_hostname() -> str: + """ + Get the local hostname for mDNS publishing. + + Overridable by environment variable ``UC_INTEGRATION_MDNS_LOCAL_HOSTNAME``. + + :return: the local hostname + """ + # Override option for announced hostname. + # Useful on macOS where it's broken for several years: local hostname keeps on changing! + # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end + + return os.getenv("UC_INTEGRATION_MDNS_LOCAL_HOSTNAME") or f"{socket.gethostname().split('.', 1)[0]}.local." From 2123207c5245ce0383061b81e3439722b17f0b1a Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 30 Oct 2023 00:13:58 +0100 Subject: [PATCH 20/25] fix: config_dir_path is always set Use ENV var UC_CONFIG_HOME for configuration directory, fallback to HOME directory or local path. --- ucapi/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 6219905..80f38b4 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Integration driver API for Remote Two. @@ -49,7 +50,7 @@ def __init__(self, loop: AbstractEventLoop): self._server_task = None self._clients = set() - self._config_dir_path: str | None = os.getenv("UC_CONFIG_HOME") + 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) @@ -628,7 +629,7 @@ def device_state(self) -> uc.DeviceStates: return self._state @property - def config_dir_path(self) -> str | None: + def config_dir_path(self) -> str: """Return configuration directory path.""" return self._config_dir_path From a8596f0285dfaf8b47435b1c90638b3fa9703664 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 30 Oct 2023 14:43:32 +0100 Subject: [PATCH 21/25] build: remove pylint threshold - exclude fixme tags - don't allow any issues anymore besides disabled ones --- .pylintrc | 19 ++----------------- ucapi/api.py | 4 ++-- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/.pylintrc b/.pylintrc index 7404d48..8ae4749 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,18 +1,3 @@ -[MAIN] -# Specify a score threshold to be exceeded before program exits with error. -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 -# specified are enabled, while categories only check already-enabled messages. -fail-on= - logging-fstring-interpolation, - logging-not-lazy, - unspecified-encoding, - consider-using-from-import, - consider-using-with, - invalid-name - [FORMAT] # Maximum number of characters on a single line. @@ -34,8 +19,8 @@ disable= too-many-instance-attributes, global-statement, too-many-arguments, - unused-argument, - too-few-public-methods + too-few-public-methods, + fixme [STRING] diff --git a/ucapi/api.py b/ucapi/api.py index 80f38b4..00cf7b9 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -447,7 +447,7 @@ 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: # TODO define custom exceptions? + except Exception as ex: # pylint: disable=W0718 # TODO define custom exceptions? _LOG.error("Exception in setup handler, aborting setup! Exception: %s", ex) return False @@ -486,7 +486,7 @@ 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: # TODO define custom exceptions? + except Exception as ex: # pylint: disable=W0718 # TODO define custom exceptions? _LOG.error("Exception in setup handler, aborting setup! Exception: %s", ex) return False From 6b764e9c1e78fbd17ba5fd29fa9674a3bf777bc0 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 30 Oct 2023 14:44:43 +0100 Subject: [PATCH 22/25] docs: prepare a CHANGELOG --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..729dbb7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# UC Integration API Python wrapper Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +_Changes in the next release_ + +### Added +- Type information +- Simple example and initial developer documentation + +### Fixed +- mDNS service publishing announces local hostname. +- ENV var handling: `UC_INTEGRATION_INTERFACE` and `UC_INTEGRATION_HTTP_PORT` are optional (#2, #3) +- config_dir_path is always set + +### Changed +- driver setup process +- entity command handler +- don't expose AsyncIOEventEmitter for event callbacks +- invalid names in public classes +- logging configuration, configuration must be done in client code + + +--- From 655bc4dffaf957468269d67efb3c283ad7f43c14 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 30 Oct 2023 16:04:18 +0100 Subject: [PATCH 23/25] docs: add a minimal hello world example --- examples/hello_integration.json | 18 ++++++++++++++ examples/hello_integration.py | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 examples/hello_integration.json create mode 100644 examples/hello_integration.py diff --git a/examples/hello_integration.json b/examples/hello_integration.json new file mode 100644 index 0000000..cd81104 --- /dev/null +++ b/examples/hello_integration.json @@ -0,0 +1,18 @@ +{ + "driver_id": "hello_integration", + "version": "0.0.1", + "min_core_api": "0.20.0", + "name": { "en": "Hello Python integration" }, + "icon": "uc:integration", + "description": { + "en": "Minimal Python integration driver example." + }, + "port": 9080, + "developer": { + "name": "Unfolded Circle ApS", + "email": "hello@unfoldedcircle.com", + "url": "https://www.unfoldedcircle.com" + }, + "home_page": "https://www.unfoldedcircle.com", + "release_date": "2023-10-30" +} diff --git a/examples/hello_integration.py b/examples/hello_integration.py new file mode 100644 index 0000000..c51a8d9 --- /dev/null +++ b/examples/hello_integration.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Hello world integration example. Bare minimum of an integration driver.""" +import asyncio +import logging +from typing import Any + +import ucapi + +loop = asyncio.get_event_loop() +api = ucapi.IntegrationAPI(loop) + + +async def cmd_handler(entity: ucapi.Button, cmd_id: str, _params: dict[str, Any] | None) -> ucapi.StatusCodes: + """ + Push button command handler. + + Called by the integration-API if a command is sent to a configured button-entity. + + :param entity: button entity + :param cmd_id: command + :param _params: optional command parameters + :return: status of the command + """ + print(f"Got {entity.id} command request: {cmd_id}") + + return ucapi.StatusCodes.OK + + +if __name__ == "__main__": + logging.basicConfig() + + button = ucapi.Button( + "button1", + "Push the button", + cmd_handler=cmd_handler, + ) + api.available_entities.add(button) + + # We are ready all the time! Otherwise, use @api.listens_to(ucapi.Events.CONNECT) & DISCONNECT + api.set_device_state(ucapi.DeviceStates.CONNECTED) + + loop.run_until_complete(api.init("hello_integration.json")) + loop.run_forever() From 104c8723a636badff2a7cc6f87297088e1feb03c Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 30 Oct 2023 16:05:21 +0100 Subject: [PATCH 24/25] docs: enhance README, add contribution guidelines --- CONTRIBUTING.md | 72 +++++++++++++++++++++++++++++++++++ README.md | 83 +++++++++++++++++++---------------------- docs/code_guidelines.md | 63 +++++++++++++++++++++++++++++++ docs/setup.md | 11 ++++++ 4 files changed, 185 insertions(+), 44 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/code_guidelines.md create mode 100644 docs/setup.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0919e65 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing + +First off, thanks for taking the time to contribute! + +Found a bug, typo, missing feature or a description that doesn't make sense or needs clarification? +Great, please let us know! + +### Bug Reports :bug: + +If you find a bug, please search for it first in the [Issues](https://github.com/unfoldedcircle/integration-python-library/issues), +and if it isn't already tracked, [create a new issue](https://github.com/unfoldedcircle/integration-python-library/issues/new). + +### New Features :bulb: + +If you'd like to see or add new functionality to the library, describe the problem you want to solve in a +[new Issue](https://github.com/unfoldedcircle/integration-python-library/issues/new). + +### Pull Requests + +**Any pull request needs to be reviewed and approved by the Unfolded Circle development team.** + +We love contributions from everyone. + +⚠️ If you plan to make substantial changes, we kindly ask you, that you please reach out to us first. +Either by opening a feature request describing your proposed changes before submitting code, or by contacting us on +one of the other [feedback channels](#feedback-speech_balloon). + +Since this library is being used in integration drivers running on the embedded Remote Two device, +we have to make sure it remains compatible with the embedded runtime environment and runs smoothly. + +Submitting pull requests for typos, formatting issues etc. are happily accepted and usually approved relatively quick. + +With that out of the way, here's the process of creating a pull request and making sure it passes the automated tests: + +### Contributing Code :bulb: + +1. Fork the repo. + +2. Make your changes or enhancements (preferably on a feature-branch). + + Contributed code must be licensed under the [Mozilla Public License 2.0](https://choosealicense.com/licenses/mpl-2.0/), + or a compatible license, if existing parts of other projects are reused (e.g. MIT licensed code). + It is required to add a boilerplate copyright notice to the top of each file: + + ``` + """ + {fileheader} + + :copyright: (c) {year} {person OR org} <{email}> + :license: MPL-2.0, see LICENSE for more details. + """ + ``` + +3. Make sure your changes follow the project's code style and the lints pass described in [Code Style](docs/code_guidelines.md). + +4. Push to your fork. + +5. Submit a pull request. + +At this point we will review the PR and give constructive feedback. +This is a time for discussion and improvements, and making the necessary changes will be required before we can +merge the contribution. + +### Feedback :speech_balloon: + +There are a few different ways to provide feedback: + +- [Create a new issue](https://github.com/unfoldedcircle/integration-python-library/issues/new) +- [Reach out to us on Twitter](https://twitter.com/unfoldedcircle) +- [Visit our community forum](http://unfolded.community/) +- [Chat with us in our Discord channel](http://unfolded.chat/) +- [Send us a message on our website](https://unfoldedcircle.com/contact) diff --git a/README.md b/README.md index 0fe3e2c..9e4229d 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,57 @@ # Python API wrapper for the UC Integration API -This is a Python library that can be used for Python based integrations. It wraps the UC Integration API. +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). -It's a pre-alpha release. Missing features will be added continuously. Based on the NodeJS implementation. +It's a pre-alpha release (in our eyes). Missing features will be added continuously. +Based on our [Node.js integration library](https://github.com/unfoldedcircle/integration-node-library). -Not supported: +❗️**Attention:** +> This is our first Python project, and we don't see ourselves as Python professionals. +> Therefore, the library is most likely not yet that Pythonic! +> We are still learning and value your feedback on how to improve it :-) -- secure WebSocket +Not yet supported: -Requires Python 3.10 or newer +- Secure WebSocket +- Token based authentication ---- +Requirements: +- Python 3.10 or newer -### Local testing: -```shell -python3 setup.py bdist_wheel -pip3 install --force-reinstall dist/ucapi-$VERSION-py3-none-any.whl -``` +## Usage -## Code Style +See [examples directory](examples) for a minimal integration driver example. -- Code line length: 120 -- Use double quotes as default (don't mix and match for simple quoting, checked with pylint). +More examples will be published. -Install tooling: -```shell -pip3 install -r test-requirements.txt -``` +### Environment Variables -### Verify +Certain features can be configured by environment variables: -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 -``` +| Variable | Values | Description | +|------------------------------------|------------------|----------------------------------------------------------------------------------------------------------------------| +| UC_CONFIG_HOME | _directory path_ | Configuration directory to save the user configuration from the driver setup.
Default: $HOME or current directory | +| UC_INTEGRATION_INTERFACE | _address_ | Listening interface for WebSocket server.
Default: `0.0.0.0` | +| UC_INTEGRATION_HTTP_PORT | _number_ | WebSocket listening port.
Default: `port` field in driver metadata json file, if not specified: `9090` | +| UC_INTEGRATION_MDNS_LOCAL_HOSTNAME | _hostname_ | Published local hostname in mDNS service announcement.
Default: _short hostname_ with `.local` domain. | +| UC_DISABLE_MDNS_PUBLISH | `true` / `false` | Disables mDNS service advertisement.
Default: `false` | -Linting integration in PyCharm/IntelliJ IDEA: -1. Install plugin [Pylint](https://plugins.jetbrains.com/plugin/11084-pylint) -2. Open Pylint window and run a scan: `Check Module` or `Check Current File` +## Versioning -### Format Code -```shell -python -m black ucapi --line-length 120 -``` +We use [SemVer](http://semver.org/) for versioning. For the versions available, see the +[tags and releases on this repository](https://github.com/unfoldedcircle/integration-python-library/releases). -PyCharm/IntelliJ IDEA integration: -1. Go to `Preferences or Settings -> Tools -> Black` -2. Configure: -- Python interpreter -- Use Black formatter: `On code reformat` & optionally `On save` -- Arguments: `--line-length 120` +## Changelog -### Sort Imports +The major changes found in each new release are listed in the [changelog](CHANGELOG.md) and +under the GitHub [releases](https://github.com/unfoldedcircle/integration-python-library/releases). -```shell -python -m isort ucapi/. -``` +## Contributions + +Please read our [contribution guidelines](./CONTRIBUTING.md) before opening a pull request. + +## License + +This project is licensed under the [**Mozilla Public License 2.0**](https://choosealicense.com/licenses/mpl-2.0/). +See the [LICENSE](LICENSE) file for details. diff --git a/docs/code_guidelines.md b/docs/code_guidelines.md new file mode 100644 index 0000000..bfe22b0 --- /dev/null +++ b/docs/code_guidelines.md @@ -0,0 +1,63 @@ +# Code Style + +This project uses the [PEP 8 – Style Guide for Python Code](https://peps.python.org/pep-0008/) as coding convention, with the +following customization: + +- Code line length: 120 +- Use double quotes as default (don't mix and match for simple quoting, checked with pylint). + +## Tooling + +Install all code linting tools: + +```shell +pip3 install -r test-requirements.txt +``` + +### Linting + +```shell +python -m pylint ucapi +``` + +- The tool is configured in `.pylintrc`. + +Linting integration in PyCharm/IntelliJ IDEA: +1. Install plugin [Pylint](https://plugins.jetbrains.com/plugin/11084-pylint) +2. Open Pylint window and run a scan: `Check Module` or `Check Current File` + +### Sort Imports + +Import statements must be sorted with [isort](https://pycqa.github.io/isort/): + +```shell +python -m isort ucapi/. +``` + +- The tool is configured in `pyproject.toml` to use the `black` code-formatting profile. + +### Format Code + +Source code is formatted with the [Black code formatting tool](https://github.com/psf/black): + +```shell +python -m black ucapi --line-length 120 +``` + +PyCharm/IntelliJ IDEA integration: +1. Go to `Preferences or Settings -> Tools -> Black` +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 +``` diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..4185fd4 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,11 @@ +# Development Setup + +This library requires Python 3.10 or newer. + +## Local installation + +```shell +python3 setup.py bdist_wheel +pip3 install --force-reinstall dist/ucapi-$VERSION-py3-none-any.whl +``` + From e4b3abd01d207c9ec7dff50ba76d5b3839c1372d Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Mon, 30 Oct 2023 16:09:19 +0100 Subject: [PATCH 25/25] rename ENV var --- README.md | 14 +++++++------- ucapi/api.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9e4229d..402b6f0 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,13 @@ More examples will be published. Certain features can be configured by environment variables: -| Variable | Values | Description | -|------------------------------------|------------------|----------------------------------------------------------------------------------------------------------------------| -| UC_CONFIG_HOME | _directory path_ | Configuration directory to save the user configuration from the driver setup.
Default: $HOME or current directory | -| UC_INTEGRATION_INTERFACE | _address_ | Listening interface for WebSocket server.
Default: `0.0.0.0` | -| UC_INTEGRATION_HTTP_PORT | _number_ | WebSocket listening port.
Default: `port` field in driver metadata json file, if not specified: `9090` | -| UC_INTEGRATION_MDNS_LOCAL_HOSTNAME | _hostname_ | Published local hostname in mDNS service announcement.
Default: _short hostname_ with `.local` domain. | -| UC_DISABLE_MDNS_PUBLISH | `true` / `false` | Disables mDNS service advertisement.
Default: `false` | +| Variable | Values | Description | +|--------------------------|------------------|----------------------------------------------------------------------------------------------------------------------| +| UC_CONFIG_HOME | _directory path_ | Configuration directory to save the user configuration from the driver setup.
Default: $HOME or current directory | +| UC_INTEGRATION_INTERFACE | _address_ | Listening interface for WebSocket server.
Default: `0.0.0.0` | +| UC_INTEGRATION_HTTP_PORT | _number_ | WebSocket listening port.
Default: `port` field in driver metadata json file, if not specified: `9090` | +| UC_MDNS_LOCAL_HOSTNAME | _hostname_ | Published local hostname in mDNS service announcement.
Default: _short hostname_ with `.local` domain. | +| UC_DISABLE_MDNS_PUBLISH | `true` / `false` | Disables mDNS service advertisement.
Default: `false` | ## Versioning diff --git a/ucapi/api.py b/ucapi/api.py index 00cf7b9..666edf7 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -704,7 +704,7 @@ def local_hostname() -> str: """ Get the local hostname for mDNS publishing. - Overridable by environment variable ``UC_INTEGRATION_MDNS_LOCAL_HOSTNAME``. + Overridable by environment variable ``UC_MDNS_LOCAL_HOSTNAME``. :return: the local hostname """ @@ -712,4 +712,4 @@ def local_hostname() -> str: # Useful on macOS where it's broken for several years: local hostname keeps on changing! # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end - return os.getenv("UC_INTEGRATION_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."