From d6c5611cbea07849ba6e3054d3ee6794a36ffce6 Mon Sep 17 00:00:00 2001 From: kennymc-c Date: Sat, 31 Jan 2026 19:36:02 +0100 Subject: [PATCH 01/43] feat: Add requests for supported entity types, version and localization --- ucapi/api.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/ucapi/api.py b/ucapi/api.py index 5e30941..2eb83ea 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -98,6 +98,8 @@ def __init__(self, loop: AbstractEventLoop | None = None): self._available_entities = Entities("available", self._loop) self._configured_entities = Entities("configured", self._loop) + self._req_id = 1 # Request ID counter for outgoing requests + self._voice_handler: VoiceStreamHandler | None = None self._voice_session_timeout: int = self.DEFAULT_VOICE_SESSION_TIMEOUT_S # Active voice sessions @@ -1156,6 +1158,54 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: """ self._events.remove_all_listeners(event) + async def get_supported_entity_types(self, websocket=None): + """Send get_supported_entity_types request and wait for response.""" + if websocket is None: + if not self._clients: + raise RuntimeError("No active websocket connection!") + websocket = next(iter(self._clients)) + req_id = self._req_id + self._req_id += 1 + request = {"kind": "req", "id": req_id, "msg": "get_supported_entity_types"} + await websocket.send(json.dumps(request)) + while True: + response = await websocket.recv() + data = json.loads(response) + if data.get("kind") == "resp" and data.get("req_id") == req_id and data.get("msg") == "supported_entity_types": + return data.get("msg_data") + + async def get_version(self, websocket=None): + """Send get_version request and wait for response.""" + if websocket is None: + if not self._clients: + raise RuntimeError("No active websocket connection!") + websocket = next(iter(self._clients)) + req_id = self._req_id + self._req_id += 1 + request = {"kind": "req", "id": req_id, "msg": "get_version"} + await websocket.send(json.dumps(request)) + while True: + response = await websocket.recv() + data = json.loads(response) + if data.get("kind") == "resp" and data.get("req_id") == req_id and data.get("msg") == "version": + return data.get("msg_data") + + async def get_localization_cfg(self, websocket=None): + """Send get_localization_cfg request and wait for response.""" + if websocket is None: + if not self._clients: + raise RuntimeError("No active websocket connection!") + websocket = next(iter(self._clients)) + req_id = self._req_id + self._req_id += 1 + request = {"kind": "req", "id": req_id, "msg": "get_localization_cfg"} + await websocket.send(json.dumps(request)) + while True: + response = await websocket.recv() + data = json.loads(response) + if data.get("kind") == "resp" and data.get("req_id") == req_id and data.get("msg") == "localization_cfg": + return data.get("msg_data") + ############## # Properties # ############## From df900620ddb9df0b187f2dcf0d544b30c4f34154 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sun, 1 Feb 2026 09:49:57 +0100 Subject: [PATCH 02/43] Working handle of websocket requests from ucapi Improved requests signatures Handled unkown entity types --- ucapi/api.py | 238 +++++++++++++++++++++++++++++++-------- ucapi/api_definitions.py | 25 ++++ 2 files changed, 213 insertions(+), 50 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 2eb83ea..f11573d 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -27,6 +27,7 @@ from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf from . import api_definitions as uc +from .api_definitions import LocalizationCfg, Version from .entities import Entities from .entity import EntityTypes from .media_player import Attributes as MediaAttr @@ -107,6 +108,13 @@ def __init__(self, loop: AbstractEventLoop | None = None): # Enforce: at most one active session per entity_id (across all websockets) self._voice_session_by_entity: dict[str, VoiceSessionKey] = {} + # One receiver per websocket (already in _handle_ws). Responses are dispatched to futures here. + self._ws_pending: dict[Any, dict[int, asyncio.Future]] = {} + self._ws_send_locks: dict[Any, asyncio.Lock] = {} + self._req_id_lock = asyncio.Lock() + + self._supported_entity_types: list[EntityTypes] | None = None + # Setup event loop asyncio.set_event_loop(self._loop) @@ -209,11 +217,17 @@ async def _start_web_socket_server(self, host: str, port: int) -> None: async def _handle_ws(self, websocket) -> None: try: self._clients.add(websocket) + # Init per-websocket pending requests map + send lock + self._ws_pending[websocket] = {} + self._ws_send_locks[websocket] = asyncio.Lock() _LOG.info("WS: Client added: %s", websocket.remote_address) # authenticate on connection await self._authenticate(websocket, True) + # Request supported entity types from remote + asyncio.create_task(self._update_supported_entity_types(websocket)) + self._events.emit(uc.Events.CLIENT_CONNECTED) async for message in websocket: @@ -263,7 +277,12 @@ async def _handle_ws(self, websocket) -> None: key[1], ex, ) - + # Cancel all pending requests for this websocket (client disconnected) + pending = self._ws_pending.pop(websocket, {}) + for _, fut in pending.items(): + if not fut.done(): + fut.set_exception(ConnectionError("WebSocket disconnected")) + self._ws_send_locks.pop(websocket, None) self._clients.remove(websocket) _LOG.info("[%s] WS: Client removed", websocket.remote_address) self._events.emit(uc.Events.CLIENT_DISCONNECTED) @@ -414,6 +433,102 @@ async def _process_ws_message(self, websocket, message) -> None: await self._handle_ws_request_msg(websocket, msg, req_id, msg_data) elif kind == "event": await self._handle_ws_event_msg(msg, msg_data) + elif kind == "resp": + # Response to a previously sent request + # Some implementations use "req_id", others use "id" + resp_id = data.get("req_id", data.get("id")) + if resp_id is None: + _LOG.warning( + "[%s] WS: Received resp without req_id/id: %s", + websocket.remote_address, + message, + ) + return + + pending = self._ws_pending.get(websocket) + if not pending: + _LOG.debug( + "[%s] WS: No pending map for resp_id=%s (late resp?)", + websocket.remote_address, + resp_id, + ) + return + fut = pending.get(int(resp_id)) + if fut is None: + _LOG.debug( + "[%s] WS: Unmatched resp_id=%s (not pending). msg=%s", + websocket.remote_address, + resp_id, + msg, + ) + return + + if not fut.done(): + fut.set_result(data) + + async def _ws_request( + self, + websocket, + msg: str, + msg_data: dict[str, Any] | None = None, + *, + timeout: float = 10.0, + ) -> dict[str, Any]: + """ + Send a request over websocket and await the matching response. + + - Uses a Future stored in self._ws_pending[websocket][req_id] + - Reader task (_handle_ws -> _process_ws_message) completes the future on 'resp' + - Raises TimeoutError on timeout + """ + if websocket is None: + if not self._clients: + raise RuntimeError("No active websocket connection!") + websocket = next(iter(self._clients)) + + # Ensure per-socket structures exist (in case you call before _handle_ws init) + if websocket not in self._ws_pending: + self._ws_pending[websocket] = {} + if websocket not in self._ws_send_locks: + self._ws_send_locks[websocket] = asyncio.Lock() + + # Allocate req_id safely + async with self._req_id_lock: + req_id = self._req_id + self._req_id += 1 + + fut = self._loop.create_future() + self._ws_pending[websocket][req_id] = fut + + try: + payload: dict[str, Any] = {"kind": "req", "id": req_id, "msg": msg} + if msg_data is not None: + payload["msg_data"] = msg_data + + if _LOG.isEnabledFor(logging.DEBUG): + _LOG.debug( + "[%s] ->: %s", + websocket.remote_address, + filter_log_msg_data(payload), + ) + # Serialize sends to avoid interleaving issues (optional but recommended) + async with self._ws_send_locks[websocket]: + await websocket.send(json.dumps(payload)) + + # Await response + resp = await asyncio.wait_for(fut, timeout=timeout) + return resp + + except asyncio.TimeoutError as ex: + raise TimeoutError( + f"Timeout waiting for response to '{msg}' (req_id={req_id})" + ) from ex + + finally: + # Cleanup pending future entry + pending = self._ws_pending.get(websocket) + if pending: + pending.pop(req_id, None) async def _process_ws_binary_message(self, websocket, data: bytes) -> None: """Process a binary WebSocket message using protobuf IntegrationMessage. @@ -694,12 +809,11 @@ async def _handle_ws_request_msg( {"state": self.device_state}, ) elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: - available_entities = self._available_entities.get_all() await self._send_ws_response( websocket, req_id, uc.WsMsgEvents.AVAILABLE_ENTITIES, - {"available_entities": available_entities}, + {"available_entities": self._available_entities.get_all()}, ) elif msg == uc.WsMessages.GET_ENTITY_STATES: entity_states = await self._configured_entities.get_states() @@ -1158,53 +1272,77 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: """ self._events.remove_all_listeners(event) - async def get_supported_entity_types(self, websocket=None): - """Send get_supported_entity_types request and wait for response.""" - if websocket is None: - if not self._clients: - raise RuntimeError("No active websocket connection!") - websocket = next(iter(self._clients)) - req_id = self._req_id - self._req_id += 1 - request = {"kind": "req", "id": req_id, "msg": "get_supported_entity_types"} - await websocket.send(json.dumps(request)) - while True: - response = await websocket.recv() - data = json.loads(response) - if data.get("kind") == "resp" and data.get("req_id") == req_id and data.get("msg") == "supported_entity_types": - return data.get("msg_data") - - async def get_version(self, websocket=None): - """Send get_version request and wait for response.""" - if websocket is None: - if not self._clients: - raise RuntimeError("No active websocket connection!") - websocket = next(iter(self._clients)) - req_id = self._req_id - self._req_id += 1 - request = {"kind": "req", "id": req_id, "msg": "get_version"} - await websocket.send(json.dumps(request)) - while True: - response = await websocket.recv() - data = json.loads(response) - if data.get("kind") == "resp" and data.get("req_id") == req_id and data.get("msg") == "version": - return data.get("msg_data") - - async def get_localization_cfg(self, websocket=None): - """Send get_localization_cfg request and wait for response.""" - if websocket is None: - if not self._clients: - raise RuntimeError("No active websocket connection!") - websocket = next(iter(self._clients)) - req_id = self._req_id - self._req_id += 1 - request = {"kind": "req", "id": req_id, "msg": "get_localization_cfg"} - await websocket.send(json.dumps(request)) - while True: - response = await websocket.recv() - data = json.loads(response) - if data.get("kind") == "resp" and data.get("req_id") == req_id and data.get("msg") == "localization_cfg": - return data.get("msg_data") + async def get_supported_entity_types( + self, websocket=None, *, timeout: float = 5.0 + ) -> list[EntityTypes]: + """Request supported entity types from client and return msg_data.""" + resp = await self._ws_request( + websocket, + "get_supported_entity_types", + timeout=timeout, + ) + if resp.get("msg") != "supported_entity_types": + _LOG.debug( + "[%s] Unexpected resp msg for get_supported_entity_types: %s", + websocket.remote_address if websocket else "", + resp.get("msg"), + ) + entity_types: list[EntityTypes] = [] + for entity_type in resp.get("msg_data", []): + try: + entity_types.append(EntityTypes(entity_type)) + except ValueError: + pass + return entity_types + + async def _update_supported_entity_types( + self, websocket=None, *, timeout: float = 5.0 + ) -> None: + """Update supported entity types by remote.""" + await asyncio.sleep(0) + self._supported_entity_types = await self.get_supported_entity_types( + websocket, timeout=timeout + ) + _LOG.debug( + "[%s] Supported entity types %s", + websocket.remote_address if websocket else "", + self._supported_entity_types, + ) + + async def get_version(self, websocket=None, *, timeout: float = 5.0) -> Version: + """Request client version and return msg_data.""" + resp = await self._ws_request( + websocket, + "get_version", + timeout=timeout, + ) + if resp.get("msg") != "version": + _LOG.debug( + "[%s] Unexpected resp msg for get_version: %s", + websocket.remote_address if websocket else "", + resp.get("msg"), + ) + + return resp.get("msg_data") + + async def get_localization_cfg( + self, websocket=None, *, timeout: float = 5.0 + ) -> LocalizationCfg: + """Request localization config and return msg_data.""" + resp = await self._ws_request( + websocket, + "get_localization_cfg", + timeout=timeout, + ) + + if resp.get("msg") != "localization_cfg": + _LOG.debug( + "[%s] Unexpected resp msg for get_localization_cfg: %s", + websocket.remote_address if websocket else "", + resp.get("msg"), + ) + + return resp.get("msg_data") ############## # Properties # diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 0b09da2..3d8a8ca 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -302,3 +302,28 @@ class AssistantEvent: entity_id: str session_id: int data: AssistantEventData | None = None + + +@dataclass +class Version: + """Version response payload sent via the ``get_version`` request.""" + + model: str + device_name: str + hostname: str + address: str + api: str + core: str + ui: str + os: str + + +@dataclass +class LocalizationCfg: + """Localization response payload sent via the ``get_localization_cfg`` request.""" + + language_code: str + country_code: str + time_zone: str + time_format_24h: bool + measurement_unit: str From b4de43b9e4f9cad51a4d5734557058c9035603ea Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:01:51 +0100 Subject: [PATCH 03/43] Replaced exception with error trace --- ucapi/api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index f11573d..c2bb0a2 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -520,10 +520,11 @@ async def _ws_request( return resp except asyncio.TimeoutError as ex: - raise TimeoutError( - f"Timeout waiting for response to '{msg}' (req_id={req_id})" - ) from ex - + _LOG.error( + f"[%s] Timeout waiting for response to '{msg}' (req_id={req_id})", + websocket.remote_address, + ex, + ) finally: # Cleanup pending future entry pending = self._ws_pending.get(websocket) From a139fed3b6c2f3ec39607cf87746e00663c59ed9 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:19:57 +0100 Subject: [PATCH 04/43] Handle exception --- ucapi/api.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index c2bb0a2..b6a578c 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -521,8 +521,10 @@ async def _ws_request( except asyncio.TimeoutError as ex: _LOG.error( - f"[%s] Timeout waiting for response to '{msg}' (req_id={req_id})", + "[%s] Timeout waiting for response to %s (req_id=%s)", websocket.remote_address, + msg, + req_id, ex, ) finally: @@ -1301,14 +1303,21 @@ async def _update_supported_entity_types( ) -> None: """Update supported entity types by remote.""" await asyncio.sleep(0) - self._supported_entity_types = await self.get_supported_entity_types( - websocket, timeout=timeout - ) - _LOG.debug( - "[%s] Supported entity types %s", - websocket.remote_address if websocket else "", - self._supported_entity_types, - ) + try: + self._supported_entity_types = await self.get_supported_entity_types( + websocket, timeout=timeout + ) + _LOG.debug( + "[%s] Supported entity types %s", + websocket.remote_address if websocket else "", + self._supported_entity_types, + ) + except Exception as ex: + _LOG.error( + "[%s] Unable to retrieve entity types %s", + websocket.remote_address if websocket else "", + ex + ) async def get_version(self, websocket=None, *, timeout: float = 5.0) -> Version: """Request client version and return msg_data.""" From fe8f47a5b92167437cba88fa646597f1da8c7a06 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Mon, 2 Feb 2026 08:15:02 +0100 Subject: [PATCH 05/43] Fixed stacktrace error, all good now --- ucapi/api.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index b6a578c..f6f693d 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -521,12 +521,13 @@ async def _ws_request( except asyncio.TimeoutError as ex: _LOG.error( - "[%s] Timeout waiting for response to %s (req_id=%s)", + "[%s] Timeout waiting for response to %s (req_id=%s) %s", websocket.remote_address, msg, req_id, ex, ) + raise ex finally: # Cleanup pending future entry pending = self._ws_pending.get(websocket) @@ -1312,11 +1313,11 @@ async def _update_supported_entity_types( websocket.remote_address if websocket else "", self._supported_entity_types, ) - except Exception as ex: + except Exception as ex: # pylint: disable=W0718 _LOG.error( "[%s] Unable to retrieve entity types %s", websocket.remote_address if websocket else "", - ex + ex, ) async def get_version(self, websocket=None, *, timeout: float = 5.0) -> Version: From c0b802bb81d28b8b58d035edf0fa2ccba1237942 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:04:35 +0100 Subject: [PATCH 06/43] Fixed blocking websocket requests : create task for each message to handle --- ucapi/api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index f6f693d..dcbd223 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -234,10 +234,12 @@ async def _handle_ws(self, websocket) -> None: # Distinguish between text (str) and binary (bytes-like) messages if isinstance(message, str): # JSON text message - await self._process_ws_message(websocket, message) + asyncio.create_task(self._process_ws_message(websocket, message)) elif isinstance(message, (bytes, bytearray, memoryview)): # Binary message (protobuf in future) - await self._process_ws_binary_message(websocket, bytes(message)) + asyncio.create_task( + self._process_ws_binary_message(websocket, bytes(message)) + ) else: _LOG.warning( "[%s] WS: Unsupported message type %s", @@ -480,6 +482,10 @@ async def _ws_request( - Uses a Future stored in self._ws_pending[websocket][req_id] - Reader task (_handle_ws -> _process_ws_message) completes the future on 'resp' - Raises TimeoutError on timeout + :param websocket: client connection + :param msg: event message name + :param msg_data: message data payload + :param timeout: timeout for message """ if websocket is None: if not self._clients: From e6e7cbab6d72b167b45f850df96c9cf75bb17014 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:56:34 +0100 Subject: [PATCH 07/43] Removed response signatures --- ucapi/api.py | 6 ++++-- ucapi/api_definitions.py | 25 ------------------------- 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index dcbd223..4ca1670 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -1326,7 +1326,9 @@ async def _update_supported_entity_types( ex, ) - async def get_version(self, websocket=None, *, timeout: float = 5.0) -> Version: + async def get_version( + self, websocket=None, *, timeout: float = 5.0 + ) -> dict[str, Any]: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1344,7 +1346,7 @@ async def get_version(self, websocket=None, *, timeout: float = 5.0) -> Version: async def get_localization_cfg( self, websocket=None, *, timeout: float = 5.0 - ) -> LocalizationCfg: + ) -> dict[str, Any]: """Request localization config and return msg_data.""" resp = await self._ws_request( websocket, diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 3d8a8ca..0b09da2 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -302,28 +302,3 @@ class AssistantEvent: entity_id: str session_id: int data: AssistantEventData | None = None - - -@dataclass -class Version: - """Version response payload sent via the ``get_version`` request.""" - - model: str - device_name: str - hostname: str - address: str - api: str - core: str - ui: str - os: str - - -@dataclass -class LocalizationCfg: - """Localization response payload sent via the ``get_localization_cfg`` request.""" - - language_code: str - country_code: str - time_zone: str - time_format_24h: bool - measurement_unit: str From d0eae3cf89fe2ad243faa5a36ba5a564e628350c Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:57:46 +0100 Subject: [PATCH 08/43] Removed response signatures --- ucapi/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ucapi/api.py b/ucapi/api.py index 4ca1670..22c3012 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -27,7 +27,6 @@ from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf from . import api_definitions as uc -from .api_definitions import LocalizationCfg, Version from .entities import Entities from .entity import EntityTypes from .media_player import Attributes as MediaAttr From 4a9e1755d70b9a37611018d164bfbb6339ad47d2 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:34:09 +0100 Subject: [PATCH 09/43] Check after supported entity types --- ucapi/api.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ucapi/api.py b/ucapi/api.py index 22c3012..fa54419 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -818,11 +818,18 @@ async def _handle_ws_request_msg( {"state": self.device_state}, ) elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: + available_entities = self._available_entities.get_all() + if self._supported_entity_types: + available_entities = [ + entity + for entity in available_entities + if entity.get("entity_type") in self._supported_entity_types + ] await self._send_ws_response( websocket, req_id, uc.WsMsgEvents.AVAILABLE_ENTITIES, - {"available_entities": self._available_entities.get_all()}, + {"available_entities": available_entities}, ) elif msg == uc.WsMessages.GET_ENTITY_STATES: entity_states = await self._configured_entities.get_states() From 110d147bd364bde8b69ad4ab21647bdef4de8190 Mon Sep 17 00:00:00 2001 From: Damien BAIN THOUVEREZ Date: Wed, 4 Feb 2026 13:19:48 +0100 Subject: [PATCH 10/43] Moved extraction of supported entity types in request for available entities --- ucapi/api.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index fa54419..266be42 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -112,7 +112,7 @@ def __init__(self, loop: AbstractEventLoop | None = None): self._ws_send_locks: dict[Any, asyncio.Lock] = {} self._req_id_lock = asyncio.Lock() - self._supported_entity_types: list[EntityTypes] | None = None + self._supported_entity_types: list[str] | None = None # Setup event loop asyncio.set_event_loop(self._loop) @@ -224,9 +224,6 @@ async def _handle_ws(self, websocket) -> None: # authenticate on connection await self._authenticate(websocket, True) - # Request supported entity types from remote - asyncio.create_task(self._update_supported_entity_types(websocket)) - self._events.emit(uc.Events.CLIENT_CONNECTED) async for message in websocket: @@ -819,6 +816,9 @@ async def _handle_ws_request_msg( ) elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: available_entities = self._available_entities.get_all() + if self._supported_entity_types is None: + # Request supported entity types from remote + await self._update_supported_entity_types(websocket) if self._supported_entity_types: available_entities = [ entity @@ -1290,7 +1290,7 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: async def get_supported_entity_types( self, websocket=None, *, timeout: float = 5.0 - ) -> list[EntityTypes]: + ) -> list[str]: """Request supported entity types from client and return msg_data.""" resp = await self._ws_request( websocket, @@ -1303,13 +1303,7 @@ async def get_supported_entity_types( websocket.remote_address if websocket else "", resp.get("msg"), ) - entity_types: list[EntityTypes] = [] - for entity_type in resp.get("msg_data", []): - try: - entity_types.append(EntityTypes(entity_type)) - except ValueError: - pass - return entity_types + return resp.get("msg_data", []) async def _update_supported_entity_types( self, websocket=None, *, timeout: float = 5.0 From e80291de2700f696b7d3426943d1337d05d2a509 Mon Sep 17 00:00:00 2001 From: Damien BAIN THOUVEREZ Date: Wed, 4 Feb 2026 16:25:00 +0100 Subject: [PATCH 11/43] Linting --- ucapi/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ucapi/api.py b/ucapi/api.py index 266be42..b1687a4 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -800,6 +800,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: async def _handle_ws_request_msg( self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None ) -> None: + # pylint: disable=R0912 if msg == uc.WsMessages.GET_DRIVER_VERSION: await self._send_ws_response( websocket, From 7a3e8a7627daabbdadf6f1969353af2b4e6fc2cf Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:00:30 +0100 Subject: [PATCH 12/43] Requested changes --- ucapi/api.py | 38 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index b1687a4..6d045b9 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -109,8 +109,6 @@ def __init__(self, loop: AbstractEventLoop | None = None): # One receiver per websocket (already in _handle_ws). Responses are dispatched to futures here. self._ws_pending: dict[Any, dict[int, asyncio.Future]] = {} - self._ws_send_locks: dict[Any, asyncio.Lock] = {} - self._req_id_lock = asyncio.Lock() self._supported_entity_types: list[str] | None = None @@ -218,7 +216,6 @@ async def _handle_ws(self, websocket) -> None: self._clients.add(websocket) # Init per-websocket pending requests map + send lock self._ws_pending[websocket] = {} - self._ws_send_locks[websocket] = asyncio.Lock() _LOG.info("WS: Client added: %s", websocket.remote_address) # authenticate on connection @@ -280,7 +277,6 @@ async def _handle_ws(self, websocket) -> None: for _, fut in pending.items(): if not fut.done(): fut.set_exception(ConnectionError("WebSocket disconnected")) - self._ws_send_locks.pop(websocket, None) self._clients.remove(websocket) _LOG.info("[%s] WS: Client removed", websocket.remote_address) self._events.emit(uc.Events.CLIENT_DISCONNECTED) @@ -483,21 +479,14 @@ async def _ws_request( :param msg_data: message data payload :param timeout: timeout for message """ - if websocket is None: - if not self._clients: - raise RuntimeError("No active websocket connection!") - websocket = next(iter(self._clients)) # Ensure per-socket structures exist (in case you call before _handle_ws init) if websocket not in self._ws_pending: self._ws_pending[websocket] = {} - if websocket not in self._ws_send_locks: - self._ws_send_locks[websocket] = asyncio.Lock() # Allocate req_id safely - async with self._req_id_lock: - req_id = self._req_id - self._req_id += 1 + req_id = self._req_id + self._req_id += 1 fut = self._loop.create_future() self._ws_pending[websocket][req_id] = fut @@ -514,8 +503,7 @@ async def _ws_request( filter_log_msg_data(payload), ) # Serialize sends to avoid interleaving issues (optional but recommended) - async with self._ws_send_locks[websocket]: - await websocket.send(json.dumps(payload)) + await websocket.send(json.dumps(payload)) # Await response resp = await asyncio.wait_for(fut, timeout=timeout) @@ -1290,7 +1278,7 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: self._events.remove_all_listeners(event) async def get_supported_entity_types( - self, websocket=None, *, timeout: float = 5.0 + self, websocket, *, timeout: float = 5.0 ) -> list[str]: """Request supported entity types from client and return msg_data.""" resp = await self._ws_request( @@ -1301,13 +1289,13 @@ async def get_supported_entity_types( if resp.get("msg") != "supported_entity_types": _LOG.debug( "[%s] Unexpected resp msg for get_supported_entity_types: %s", - websocket.remote_address if websocket else "", + websocket.remote_address, resp.get("msg"), ) return resp.get("msg_data", []) async def _update_supported_entity_types( - self, websocket=None, *, timeout: float = 5.0 + self, websocket, *, timeout: float = 5.0 ) -> None: """Update supported entity types by remote.""" await asyncio.sleep(0) @@ -1317,19 +1305,17 @@ async def _update_supported_entity_types( ) _LOG.debug( "[%s] Supported entity types %s", - websocket.remote_address if websocket else "", + websocket.remote_address, self._supported_entity_types, ) except Exception as ex: # pylint: disable=W0718 _LOG.error( "[%s] Unable to retrieve entity types %s", - websocket.remote_address if websocket else "", + websocket.remote_address, ex, ) - async def get_version( - self, websocket=None, *, timeout: float = 5.0 - ) -> dict[str, Any]: + async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any]: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1339,14 +1325,14 @@ async def get_version( if resp.get("msg") != "version": _LOG.debug( "[%s] Unexpected resp msg for get_version: %s", - websocket.remote_address if websocket else "", + websocket.remote_address, resp.get("msg"), ) return resp.get("msg_data") async def get_localization_cfg( - self, websocket=None, *, timeout: float = 5.0 + self, websocket, *, timeout: float = 5.0 ) -> dict[str, Any]: """Request localization config and return msg_data.""" resp = await self._ws_request( @@ -1358,7 +1344,7 @@ async def get_localization_cfg( if resp.get("msg") != "localization_cfg": _LOG.debug( "[%s] Unexpected resp msg for get_localization_cfg: %s", - websocket.remote_address if websocket else "", + websocket.remote_address, resp.get("msg"), ) From 5e1ab8ceace492c53adf889af840850bb50aac57 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:27:50 +0100 Subject: [PATCH 13/43] Linting and removed comment --- ucapi/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ucapi/api.py b/ucapi/api.py index 6d045b9..2466efb 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -502,7 +502,6 @@ async def _ws_request( websocket.remote_address, filter_log_msg_data(payload), ) - # Serialize sends to avoid interleaving issues (optional but recommended) await websocket.send(json.dumps(payload)) # Await response From 4a413fc34029d59088b66b41a127e6df4ca347ea Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:29:46 +0100 Subject: [PATCH 14/43] Linting flake8 --- ucapi/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ucapi/api.py b/ucapi/api.py index 2466efb..b24984f 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -479,7 +479,6 @@ async def _ws_request( :param msg_data: message data payload :param timeout: timeout for message """ - # Ensure per-socket structures exist (in case you call before _handle_ws init) if websocket not in self._ws_pending: self._ws_pending[websocket] = {} From 335b9a33930817263e662bfce85851eee71537ee Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Wed, 18 Feb 2026 17:05:04 +0100 Subject: [PATCH 15/43] fix: merge from main --- ucapi/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ucapi/api.py b/ucapi/api.py index c214032..47cd2ff 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -428,7 +428,7 @@ async def _process_ws_message(self, websocket, message) -> None: else: await self._handle_ws_request_msg(websocket, msg, req_id, msg_data) elif kind == "event": - await self._handle_ws_event_msg(msg, msg_data) + await self._handle_ws_event_msg(websocket, msg, msg_data) elif kind == "resp": # Response to a previously sent request # Some implementations use "req_id", others use "id" From 55b4c452465968f745fb7b6a0403b75e0571d5fe Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:44:13 +0100 Subject: [PATCH 16/43] Added media browsing request --- ucapi/api.py | 41 ++++++++++++++++++++++++++++++++++++++++ ucapi/api_definitions.py | 1 + ucapi/entity.py | 19 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/ucapi/api.py b/ucapi/api.py index d0239cd..1bab7c0 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -28,6 +28,7 @@ from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf +from . import StatusCodes from . import api_definitions as uc from .entities import Entities from .entity import EntityTypes @@ -711,6 +712,8 @@ async def _handle_ws_request_msg( ) elif msg == uc.WsMessages.ENTITY_COMMAND: await self._entity_command(websocket, req_id, msg_data) + elif msg == uc.WsMessages.BROWSE_MEDIA: + await self._browse_media(websocket, req_id, msg_data) elif msg == uc.WsMessages.SUBSCRIBE_EVENTS: await self._subscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) @@ -922,6 +925,44 @@ async def _entity_command( await self.acknowledge_command(websocket, req_id, result) + async def _browse_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: + if not msg_data: + _LOG.warning("Ignoring entity command: called with empty msg_data") + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) + return + + entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None + if entity_id is None: + _LOG.warning("Ignoring command: missing entity_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 browse media for '%s': no configured entity found", + entity_id, + ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) + return + + result = await entity.browse_media( + msg_data, + websocket=websocket, + ) + if isinstance(result, dict): + await self._send_ws_response( + websocket, req_id, "media_browse", result, StatusCodes.OK + ) + else: + await self.acknowledge_command(websocket, req_id, result) + async def _setup_driver( self, websocket, req_id: int, msg_data: dict[str, Any] | None ) -> bool: diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index ad6cc77..01449cf 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -59,6 +59,7 @@ class WsMessages(str, Enum): GET_DRIVER_METADATA = "get_driver_metadata" SETUP_DRIVER = "setup_driver" SET_DRIVER_USER_DATA = "set_driver_user_data" + BROWSE_MEDIA = "browse_media" # Does WsMsgEvents need to be public? diff --git a/ucapi/entity.py b/ucapi/entity.py index 3b0466b..0f7473d 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -147,3 +147,22 @@ async def command( return await handler(self, cmd_id, params, websocket=websocket) return await handler(self, cmd_id, params) + + # pylint: disable=W0613 + async def browse_media( + self, + params: dict[str, Any], + *, + websocket: Any, + ) -> dict[str, Any] | StatusCodes: + """ + Execute entity browsing request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param params: browsing parameters + :param websocket: optional websocket connection. Allows for directed event + callbacks instead of broadcasts. + :return: browsing response or status code if any error occurs + """ + return StatusCodes.NOT_IMPLEMENTED From 5140403ea4fb6c12d5c3e4d4d375208827108429 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:04:39 +0100 Subject: [PATCH 17/43] Added missing methods and commands --- ucapi/api.py | 40 +++++++++++++++++++++++++ ucapi/api_definitions.py | 1 + ucapi/entity.py | 19 ++++++++++++ ucapi/media_player.py | 64 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/ucapi/api.py b/ucapi/api.py index 1bab7c0..9501c10 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -714,6 +714,8 @@ async def _handle_ws_request_msg( await self._entity_command(websocket, req_id, msg_data) elif msg == uc.WsMessages.BROWSE_MEDIA: await self._browse_media(websocket, req_id, msg_data) + elif msg == uc.WsMessages.SEARCH_MEDIA: + await self._search_media(websocket, req_id, msg_data) elif msg == uc.WsMessages.SUBSCRIBE_EVENTS: await self._subscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) @@ -963,6 +965,44 @@ async def _browse_media( else: await self.acknowledge_command(websocket, req_id, result) + async def _search_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: + if not msg_data: + _LOG.warning("Ignoring entity command: called with empty msg_data") + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) + return + + entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None + if entity_id is None: + _LOG.warning("Ignoring command: missing entity_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 search media for '%s': no configured entity found", + entity_id, + ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) + return + + result = await entity.search_media( + msg_data, + websocket=websocket, + ) + if isinstance(result, dict): + await self._send_ws_response( + websocket, req_id, "search_media", result, StatusCodes.OK + ) + else: + await self.acknowledge_command(websocket, req_id, result) + async def _setup_driver( self, websocket, req_id: int, msg_data: dict[str, Any] | None ) -> bool: diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 01449cf..3f56c3c 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -60,6 +60,7 @@ class WsMessages(str, Enum): SETUP_DRIVER = "setup_driver" SET_DRIVER_USER_DATA = "set_driver_user_data" BROWSE_MEDIA = "browse_media" + SEARCH_MEDIA = "search_media" # Does WsMsgEvents need to be public? diff --git a/ucapi/entity.py b/ucapi/entity.py index 0f7473d..93b6974 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -166,3 +166,22 @@ async def browse_media( :return: browsing response or status code if any error occurs """ return StatusCodes.NOT_IMPLEMENTED + + # pylint: disable=W0613 + async def search_media( + self, + params: dict[str, Any], + *, + websocket: Any, + ) -> dict[str, Any] | StatusCodes: + """ + Execute media search request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param params: search parameters + :param websocket: optional websocket connection. Allows for directed event + callbacks instead of broadcasts. + :return: search response or status code if any error occurs + """ + return StatusCodes.NOT_IMPLEMENTED diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 046d0e0..501ae41 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -68,6 +68,11 @@ class Features(str, Enum): SUBTITLE = "subtitle" RECORD = "record" SETTINGS = "settings" + PLAY_MEDIA = "play_media" + CLEAR_PLAYLIST = "clear_playlist" + BROWSE_MEDIA = "browse_media" + SEARCH_MEDIA = "search_media" + SEARCH_MEDIA_CLASSES = "search_media_classes" class Attributes(str, Enum): @@ -151,6 +156,7 @@ class Commands(str, Enum): SUBTITLE = "subtitle" SETTINGS = "settings" SEARCH = "search" + PLAY_MEDIA = "play_media" class DeviceClasses(str, Enum): @@ -188,6 +194,64 @@ class RepeatMode(str, Enum): ONE = "ONE" +class MediaPlayAction(str, Enum): + """Media Play actions.""" + + PLAY_NOW = "PLAY_NOW" + PLAY_NEXT = "PLAY_NEXT" + ADD_TO_QUEUE = "ADD_TO_QUEUE" + + +class MediaContent(str, Enum): + """Media content types for media browsing.""" + + ALBUM = "album" + APP = "app" + APPS = "apps" + ARTIST = "artist" + CHANNEL = "channel" + CHANNELS = "channels" + COMPOSER = "composer" + EPISODE = "episode" + GAME = "game" + GENRE = "genre" + IMAGE = "image" + MOVIE = "movie" + MUSIC = "music" + PLAYLIST = "playlist" + PODCAST = "podcast" + RADIO = "radio" + SEASON = "season" + TRACK = "track" + TV_SHOW = "tv_show" + URL = "url" + VIDEO = "video" + + +class MediaClass(str, Enum): + """Media classes for media browsing.""" + + ALBUM = "album" + APP = "app" + ARTIST = "artist" + CHANNEL = "channel" + COMPOSER = "composer" + DIRECTORY = "directory" + EPISODE = "episode" + GAME = "game" + GENRE = "genre" + IMAGE = "image" + MOVIE = "movie" + MUSIC = "music" + PLAYLIST = "playlist" + PODCAST = "podcast" + SEASON = "season" + TRACK = "track" + TV_SHOW = "tv_show" + URL = "url" + VIDEO = "video" + + class MediaPlayer(Entity): """ Media-player entity class. From 6d0a3a5f2dad06e0e99e83346a90a30ed04e0097 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:55:27 +0100 Subject: [PATCH 18/43] Fixed search media response type --- ucapi/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ucapi/api.py b/ucapi/api.py index 9501c10..93dbd57 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -998,7 +998,7 @@ async def _search_media( ) if isinstance(result, dict): await self._send_ws_response( - websocket, req_id, "search_media", result, StatusCodes.OK + websocket, req_id, "media_search", result, StatusCodes.OK ) else: await self.acknowledge_command(websocket, req_id, result) From 22b01ee3c920706f7fb2d12ef2ad287a8d7b539a Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:41:48 +0100 Subject: [PATCH 19/43] Added clients extraction (hack). To be improved --- ucapi/api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ucapi/api.py b/ucapi/api.py index 8da0291..6727e2a 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -1498,6 +1498,11 @@ async def get_localization_cfg( # Properties # ############## + @property + def clients(self) -> set: + """Return all clients.""" + return self._clients.copy() + @property def client_count(self) -> int: """Return number of WebSocket clients.""" From a4bbcf60e6b8bb4b187329f22749a6eaae7f57dd Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:54:30 +0100 Subject: [PATCH 20/43] Create task is necessary to avoid blocking --- ucapi/api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 6727e2a..3ff2dfe 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -230,10 +230,12 @@ async def _handle_ws(self, websocket) -> None: # Distinguish between text (str) and binary (bytes-like) messages if isinstance(message, str): # JSON text message - await self._process_ws_message(websocket, message) + asyncio.create_task(self._process_ws_message(websocket, message)) elif isinstance(message, (bytes, bytearray, memoryview)): # Binary message (protobuf in future) - await self._process_ws_binary_message(websocket, bytes(message)) + asyncio.create_task( + self._process_ws_binary_message(websocket, bytes(message)) + ) else: _LOG.warning( "[%s] WS: Unsupported message type %s", From e55a4b473032ca8c423ca0434152e92880f9acb5 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:41:29 +0100 Subject: [PATCH 21/43] Refactoring and added typed definitions --- ucapi/api.py | 92 +++++++++++----- ucapi/api_definitions.py | 224 ++++++++++++++++++++++++++++++++++++++- ucapi/entity.py | 38 ------- ucapi/media_player.py | 94 ++++++++-------- 4 files changed, 334 insertions(+), 114 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 93dbd57..4b5e0ef 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -30,9 +30,18 @@ from . import StatusCodes from . import api_definitions as uc +from .api_definitions import ( + BrowseMediaMsgData, + BrowseOptions, + BrowseResults, + SearchMediaMsgData, + SearchOptions, + WsMsgEvents, +) from .entities import Entities from .entity import EntityTypes from .media_player import Attributes as MediaAttr +from .media_player import MediaPlayer # Classes are dynamically created at runtime using the Google Protobuf builder pattern. # pylint: disable=no-name-in-module @@ -677,6 +686,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: ctx.session.end(VoiceEndReason.TIMEOUT) await self._cleanup_voice_session(key) + # pylint: disable=R0912 async def _handle_ws_request_msg( self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None ) -> None: @@ -946,24 +956,40 @@ async def _browse_media( return entity = self.configured_entities.get(entity_id) - if entity is None: + if entity is None or not isinstance(entity, MediaPlayer): _LOG.warning( - "Cannot browse media for '%s': no configured entity found", + "Cannot browse media for '%s': no configured entity found or entity is not a media-player", entity_id, ) await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) return - - result = await entity.browse_media( - msg_data, - websocket=websocket, - ) - if isinstance(result, dict): - await self._send_ws_response( - websocket, req_id, "media_browse", result, StatusCodes.OK + try: + data = BrowseMediaMsgData(**msg_data) + result = await entity.browse( + BrowseOptions( + media_id=data.media_id, + media_type=data.media_type, + stable_ids=data.stable_ids, + paging=data.paging, + ) + ) + if isinstance(result, BrowseResults): + await self._send_ws_response( + websocket, + req_id, + WsMsgEvents.MEDIA_BROWSE, + asdict(result), + StatusCodes.OK, + ) + else: + await self.acknowledge_command(websocket, req_id, result) + except TypeError: + _LOG.error( + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST ) - else: - await self.acknowledge_command(websocket, req_id, result) async def _search_media( self, websocket, req_id: int, msg_data: dict[str, Any] | None @@ -984,24 +1010,42 @@ async def _search_media( return entity = self.configured_entities.get(entity_id) - if entity is None: + if entity is None or not isinstance(entity, MediaPlayer): _LOG.warning( - "Cannot search media for '%s': no configured entity found", + "Cannot search media for '%s': no configured entity found or entity is not a media-player", entity_id, ) await self.acknowledge_command(websocket, req_id, uc.StatusCodes.NOT_FOUND) return - - result = await entity.search_media( - msg_data, - websocket=websocket, - ) - if isinstance(result, dict): - await self._send_ws_response( - websocket, req_id, "media_search", result, StatusCodes.OK + try: + data = SearchMediaMsgData(**msg_data) + result = await entity.search( + SearchOptions( + query=data.query, + media_id=data.media_id, + media_type=data.media_type, + stable_ids=data.stable_ids, + filter=data.filter, + paging=data.paging, + ) + ) + if isinstance(result, BrowseResults): + await self._send_ws_response( + websocket, + req_id, + WsMsgEvents.MEDIA_BROWSE, + asdict(result), + StatusCodes.OK, + ) + else: + await self.acknowledge_command(websocket, req_id, result) + except TypeError: + _LOG.error( + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST ) - else: - await self.acknowledge_command(websocket, req_id, result) async def _setup_driver( self, websocket, req_id: int, msg_data: dict[str, Any] | None diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 3f56c3c..c920bf2 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -5,7 +5,8 @@ :license: MPL-2.0, see LICENSE for more details. """ -from dataclasses import dataclass +import dataclasses +from dataclasses import dataclass, field, fields from enum import Enum, IntEnum from typing import Any, Awaitable, Callable, TypeAlias @@ -80,6 +81,8 @@ class WsMsgEvents(str, Enum): DRIVER_SETUP_CHANGE = "driver_setup_change" ABORT_DRIVER_SETUP = "abort_driver_setup" ASSISTANT_EVENT = "assistant_event" + MEDIA_BROWSE = "media_browse" + MEDIA_SEARCH = "media_search" class Events(str, Enum): @@ -356,3 +359,222 @@ class AssistantEvent: entity_id: str session_id: int data: AssistantEventData | None = None + + +class MediaContentType(str, Enum): + """Media content types for media browsing.""" + + ALBUM = "album" + APP = "app" + APPS = "apps" + ARTIST = "artist" + CHANNEL = "channel" + CHANNELS = "channels" + COMPOSER = "composer" + EPISODE = "episode" + GAME = "game" + GENRE = "genre" + IMAGE = "image" + MOVIE = "movie" + MUSIC = "music" + PLAYLIST = "playlist" + PODCAST = "podcast" + RADIO = "radio" + SEASON = "season" + TRACK = "track" + TV_SHOW = "tv_show" + URL = "url" + VIDEO = "video" + + +class MediaClass(str, Enum): + """Media classes for media browsing.""" + + ALBUM = "album" + APP = "app" + ARTIST = "artist" + CHANNEL = "channel" + COMPOSER = "composer" + DIRECTORY = "directory" + EPISODE = "episode" + GAME = "game" + GENRE = "genre" + IMAGE = "image" + MOVIE = "movie" + MUSIC = "music" + PLAYLIST = "playlist" + PODCAST = "podcast" + SEASON = "season" + TRACK = "track" + TV_SHOW = "tv_show" + URL = "url" + VIDEO = "video" + + +@dataclass +class PagingOptions: + """ + Pagination options. + + Attributes: + page (int | None): + Page number, 1-based. + limit (int | None): + Number of items returned per page. + """ + + page: int | None + limit: int | None + + +@dataclass +class BrowseOptions: + """ + Browsing media options. + + Attributes: + media_id (str | None): + Optional media content ID to restrict browsing. + media_type (MediaContentType | None): + Optional media content type to restrict browsing. + stable_ids (bool | None): + Hint to the integration to return stable media IDs. + paging (PagingOptions | None): + Optional paging object to limit returned items. + """ + + media_id: str | None + media_type: MediaContentType | None + stable_ids: bool | None + paging: PagingOptions | None + + +@dataclass +class SearchMediaFilter: + """ + Search media filter options. + + Attributes: + media_classes (list[MediaClass]|None): + Optional list of media classes to filter the results. + artist (str|None): + Optional artist name. + album (str|None): + Optional album name. + """ + + media_classes: list[MediaClass] | None + artist: str | None + album: str | None + + +@dataclass +class SearchOptions(BrowseOptions): + """ + Browsing media request message. + + Attributes: + query (str): + Free text search query. + filter (MediaContentType | None): + Optional media content type to restrict browsing. + stable_ids (bool | None): + Hint to the integration to return stable media IDs. + paging (PagingOptions | None): + Optional paging object to limit returned items. + """ + + query: str + filter: SearchMediaFilter | None + + +@dataclass +class BrowseMediaMsgData(BrowseOptions): + """ + Browsing media request message. + + Attributes: + entity_id (str): + media-player entity ID to browse. + """ + + entity_id: str + + +@dataclass +class SearchMediaMsgData(BrowseOptions): + """ + Search media request message. + + Attributes: + entity_id (str): + media-player entity ID to browse. + query (str): + Free text search query. + filter (SearchMediaFilter|None): + Additional user filter to limit the search scope. + """ + + entity_id: str + query: str + filter: SearchMediaFilter | None + + +@dataclass +class BrowseMediaItem: + """Browse Media Item object.""" + + title: str + media_class: str + media_type: str + media_id: str + can_browse: bool = field(default=False) + can_play: bool = field(default=False) + can_search: bool = field(default=False) + subtitle: str | None = field(default=None) + artist: str | None = field(default=None) + album: str | None = field(default=None) + thumbnail: str | None = field(default=None) + duration: int | None = field(default=None) + items: list["BrowseMediaItem"] | None = field(default=None) + + def __post_init__(self): + """Apply default values on missing fields.""" + for attribute in fields(self): + if ( + not isinstance(attribute.default, dataclasses.MISSING.__class__) + and getattr(self, attribute.name) is None + ): + setattr(self, attribute.name, attribute.default) + + +@dataclass +class BrowseResults: + """ + Browsing media results. + + Attributes: + media (BrowseMediaItem | None): + The browsed media item, or `undefined` if not found. + paging (PagingOptions): + Pagination metadata for this result page. + """ + + media: BrowseMediaItem | None + paging: PagingOptions + + +@dataclass +class SearchResults: + """ + Browsing media results. + + Attributes: + media (list[BrowseMediaItem]): + Array of matching media items. Pass an empty array if no results were found. + paging (PagingOptions): + Pagination metadata for this result page. + """ + + media: list[BrowseMediaItem] + paging: PagingOptions diff --git a/ucapi/entity.py b/ucapi/entity.py index 93b6974..3b0466b 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -147,41 +147,3 @@ async def command( return await handler(self, cmd_id, params, websocket=websocket) return await handler(self, cmd_id, params) - - # pylint: disable=W0613 - async def browse_media( - self, - params: dict[str, Any], - *, - websocket: Any, - ) -> dict[str, Any] | StatusCodes: - """ - Execute entity browsing request. - - Returns NOT_IMPLEMENTED if no handler is installed. - - :param params: browsing parameters - :param websocket: optional websocket connection. Allows for directed event - callbacks instead of broadcasts. - :return: browsing response or status code if any error occurs - """ - return StatusCodes.NOT_IMPLEMENTED - - # pylint: disable=W0613 - async def search_media( - self, - params: dict[str, Any], - *, - websocket: Any, - ) -> dict[str, Any] | StatusCodes: - """ - Execute media search request. - - Returns NOT_IMPLEMENTED if no handler is installed. - - :param params: search parameters - :param websocket: optional websocket connection. Allows for directed event - callbacks instead of broadcasts. - :return: search response or status code if any error occurs - """ - return StatusCodes.NOT_IMPLEMENTED diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 501ae41..a9da243 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -5,12 +5,22 @@ :license: MPL-2.0, see LICENSE for more details. """ +import logging from enum import Enum from typing import Any -from .api_definitions import CommandHandler +from .api_definitions import ( + BrowseOptions, + BrowseResults, + CommandHandler, + SearchOptions, + SearchResults, + StatusCodes, +) from .entity import Entity, EntityTypes +_LOG = logging.getLogger(__name__) + class States(str, Enum): """Media-player entity states.""" @@ -202,56 +212,6 @@ class MediaPlayAction(str, Enum): ADD_TO_QUEUE = "ADD_TO_QUEUE" -class MediaContent(str, Enum): - """Media content types for media browsing.""" - - ALBUM = "album" - APP = "app" - APPS = "apps" - ARTIST = "artist" - CHANNEL = "channel" - CHANNELS = "channels" - COMPOSER = "composer" - EPISODE = "episode" - GAME = "game" - GENRE = "genre" - IMAGE = "image" - MOVIE = "movie" - MUSIC = "music" - PLAYLIST = "playlist" - PODCAST = "podcast" - RADIO = "radio" - SEASON = "season" - TRACK = "track" - TV_SHOW = "tv_show" - URL = "url" - VIDEO = "video" - - -class MediaClass(str, Enum): - """Media classes for media browsing.""" - - ALBUM = "album" - APP = "app" - ARTIST = "artist" - CHANNEL = "channel" - COMPOSER = "composer" - DIRECTORY = "directory" - EPISODE = "episode" - GAME = "game" - GENRE = "genre" - IMAGE = "image" - MOVIE = "movie" - MUSIC = "music" - PLAYLIST = "playlist" - PODCAST = "podcast" - SEASON = "season" - TRACK = "track" - TV_SHOW = "tv_show" - URL = "url" - VIDEO = "video" - - class MediaPlayer(Entity): """ Media-player entity class. @@ -295,3 +255,35 @@ def __init__( area=area, cmd_handler=cmd_handler, ) + + async def browse(self, options: BrowseOptions) -> BrowseResults | StatusCodes: + """ + Execute entity browsing request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param options: browsing parameters + :return: browsing response or status code if any error occurs + """ + _LOG.warning( + "Media browsing not supported for %s. Request: %s", + self.id, + options, + ) + return StatusCodes.NOT_IMPLEMENTED + + async def search(self, query: SearchOptions) -> SearchResults | StatusCodes: + """ + Execute media search request. + + Returns NOT_IMPLEMENTED if no handler is installed. + + :param query: search parameters + :return: search response or status code if any error occurs + """ + _LOG.warning( + "Media searching not supported for %s. Request: %s", + self.id, + query, + ) + return StatusCodes.NOT_IMPLEMENTED From 69ab522b24f588bef8c7ffb3ed042fc0acb9b601 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:27:02 +0100 Subject: [PATCH 22/43] Finalized & tested updated API with browsing/search support --- ucapi/api.py | 6 ++- ucapi/api_definitions.py | 113 ++++++++++++++++++++++++--------------- ucapi/media_player.py | 6 +-- 3 files changed, 78 insertions(+), 47 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 4b5e0ef..1d9179a 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -34,6 +34,8 @@ BrowseMediaMsgData, BrowseOptions, BrowseResults, + PagingOptions, + SearchMediaFilter, SearchMediaMsgData, SearchOptions, WsMsgEvents, @@ -1025,8 +1027,8 @@ async def _search_media( media_id=data.media_id, media_type=data.media_type, stable_ids=data.stable_ids, - filter=data.filter, - paging=data.paging, + filter=SearchMediaFilter(data.filter) if data.filter else None, + paging=PagingOptions(data.paging), ) ) if isinstance(result, BrowseResults): diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index c920bf2..94a7730 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -5,8 +5,7 @@ :license: MPL-2.0, see LICENSE for more details. """ -import dataclasses -from dataclasses import dataclass, field, fields +from dataclasses import dataclass from enum import Enum, IntEnum from typing import Any, Awaitable, Callable, TypeAlias @@ -423,8 +422,27 @@ class PagingOptions: Number of items returned per page. """ - page: int | None - limit: int | None + page: int | None = None + limit: int | None = None + + +@dataclass +class Pagination: + """ + Pagination metadata returned by the client. + + Attributes: + page (int): + Current page number, 1-based. Must correspond to the requested page. + limit (int): + Number of items returned in this page (1–100). + count (int|None): + Optional if known: Total number of available items across all pages. + """ + + page: int + limit: int + count: int | None = None @dataclass @@ -443,10 +461,16 @@ class BrowseOptions: Optional paging object to limit returned items. """ - media_id: str | None - media_type: MediaContentType | None - stable_ids: bool | None - paging: PagingOptions | None + media_id: str | None = None + media_type: MediaContentType | None = None + stable_ids: bool | None = None + paging: PagingOptions | None = None + + def __post_init__(self): + if isinstance(self.media_type, str): + self.media_type = MediaContentType(self.media_type) + if isinstance(self.paging, dict): + self.paging = PagingOptions(**self.paging) @dataclass @@ -463,12 +487,18 @@ class SearchMediaFilter: Optional album name. """ - media_classes: list[MediaClass] | None - artist: str | None - album: str | None + media_classes: list[MediaClass] | None = None + artist: str | None = None + album: str | None = None + def __post_init__(self): + if self.media_classes: + self.media_classes = [ + MediaClass(media_class) for media_class in self.media_classes + ] -@dataclass + +@dataclass(kw_only=True) class SearchOptions(BrowseOptions): """ Browsing media request message. @@ -485,10 +515,14 @@ class SearchOptions(BrowseOptions): """ query: str - filter: SearchMediaFilter | None + filter: SearchMediaFilter | None = None + def __post_init__(self): + if isinstance(self.filter, dict): + self.filter = SearchMediaFilter(**self.filter) -@dataclass + +@dataclass(kw_only=True) class BrowseMediaMsgData(BrowseOptions): """ Browsing media request message. @@ -501,7 +535,7 @@ class BrowseMediaMsgData(BrowseOptions): entity_id: str -@dataclass +@dataclass(kw_only=True) class SearchMediaMsgData(BrowseOptions): """ Search media request message. @@ -517,7 +551,11 @@ class SearchMediaMsgData(BrowseOptions): entity_id: str query: str - filter: SearchMediaFilter | None + filter: SearchMediaFilter | None = None + + def __post_init__(self): + if isinstance(self.filter, dict): + self.filter = SearchMediaFilter(**self.filter) @dataclass @@ -528,27 +566,18 @@ class BrowseMediaItem: media_class: str media_type: str media_id: str - can_browse: bool = field(default=False) - can_play: bool = field(default=False) - can_search: bool = field(default=False) - subtitle: str | None = field(default=None) - artist: str | None = field(default=None) - album: str | None = field(default=None) - thumbnail: str | None = field(default=None) - duration: int | None = field(default=None) - items: list["BrowseMediaItem"] | None = field(default=None) - - def __post_init__(self): - """Apply default values on missing fields.""" - for attribute in fields(self): - if ( - not isinstance(attribute.default, dataclasses.MISSING.__class__) - and getattr(self, attribute.name) is None - ): - setattr(self, attribute.name, attribute.default) - - -@dataclass + can_browse: bool | None = None + can_play: bool | None = None + can_search: bool | None = None + subtitle: str | None = None + artist: str | None = None + album: str | None = None + thumbnail: str | None = None + duration: int | None = None + items: list["BrowseMediaItem"] | None = None + + +@dataclass(kw_only=True) class BrowseResults: """ Browsing media results. @@ -556,12 +585,12 @@ class BrowseResults: Attributes: media (BrowseMediaItem | None): The browsed media item, or `undefined` if not found. - paging (PagingOptions): + pagination (Pagination): Pagination metadata for this result page. """ - media: BrowseMediaItem | None - paging: PagingOptions + media: BrowseMediaItem | None = None + pagination: Pagination @dataclass @@ -572,9 +601,9 @@ class SearchResults: Attributes: media (list[BrowseMediaItem]): Array of matching media items. Pass an empty array if no results were found. - paging (PagingOptions): + pagination (Pagination): Pagination metadata for this result page. """ media: list[BrowseMediaItem] - paging: PagingOptions + pagination: Pagination diff --git a/ucapi/media_player.py b/ucapi/media_player.py index a9da243..4ed3e32 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -272,18 +272,18 @@ async def browse(self, options: BrowseOptions) -> BrowseResults | StatusCodes: ) return StatusCodes.NOT_IMPLEMENTED - async def search(self, query: SearchOptions) -> SearchResults | StatusCodes: + async def search(self, options: SearchOptions) -> SearchResults | StatusCodes: """ Execute media search request. Returns NOT_IMPLEMENTED if no handler is installed. - :param query: search parameters + :param options: search parameters :return: search response or status code if any error occurs """ _LOG.warning( "Media searching not supported for %s. Request: %s", self.id, - query, + options, ) return StatusCodes.NOT_IMPLEMENTED From 0665b31cc4e5ba5fef9b0102c7e57cb5962f44ee Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:01:00 +0100 Subject: [PATCH 23/43] Removed forced media_type to MediaContentType as it can be custom --- ucapi/api_definitions.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 94a7730..00f2d21 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -453,7 +453,7 @@ class BrowseOptions: Attributes: media_id (str | None): Optional media content ID to restrict browsing. - media_type (MediaContentType | None): + media_type (str | None): Optional media content type to restrict browsing. stable_ids (bool | None): Hint to the integration to return stable media IDs. @@ -462,13 +462,11 @@ class BrowseOptions: """ media_id: str | None = None - media_type: MediaContentType | None = None + media_type: str | None = None stable_ids: bool | None = None paging: PagingOptions | None = None def __post_init__(self): - if isinstance(self.media_type, str): - self.media_type = MediaContentType(self.media_type) if isinstance(self.paging, dict): self.paging = PagingOptions(**self.paging) @@ -506,12 +504,8 @@ class SearchOptions(BrowseOptions): Attributes: query (str): Free text search query. - filter (MediaContentType | None): - Optional media content type to restrict browsing. - stable_ids (bool | None): - Hint to the integration to return stable media IDs. - paging (PagingOptions | None): - Optional paging object to limit returned items. + filter (SearchMediaFilter | None): + Optional media filter to restrict search. """ query: str From 8f9f553578911a887a26c51921a15b35e733c0ed Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:47:01 +0100 Subject: [PATCH 24/43] Fixed bug --- ucapi/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 1d9179a..6576cea 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -1027,8 +1027,8 @@ async def _search_media( media_id=data.media_id, media_type=data.media_type, stable_ids=data.stable_ids, - filter=SearchMediaFilter(data.filter) if data.filter else None, - paging=PagingOptions(data.paging), + filter=data.filter, + paging=data.paging, ) ) if isinstance(result, BrowseResults): From 7c0e5851f2e53e54ba25846361349b48ff447ed2 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:59:32 +0100 Subject: [PATCH 25/43] Fixed bug --- ucapi/api_definitions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index 00f2d21..e375904 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -512,6 +512,7 @@ class SearchOptions(BrowseOptions): filter: SearchMediaFilter | None = None def __post_init__(self): + super().__post_init__() if isinstance(self.filter, dict): self.filter = SearchMediaFilter(**self.filter) @@ -528,6 +529,9 @@ class BrowseMediaMsgData(BrowseOptions): entity_id: str + def __post_init__(self): + super().__post_init__() + @dataclass(kw_only=True) class SearchMediaMsgData(BrowseOptions): @@ -548,6 +552,7 @@ class SearchMediaMsgData(BrowseOptions): filter: SearchMediaFilter | None = None def __post_init__(self): + super().__post_init__() if isinstance(self.filter, dict): self.filter = SearchMediaFilter(**self.filter) From 0081b3d95051c12f960d5b9b501c8fb96ccdd5be Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:14:28 +0100 Subject: [PATCH 26/43] Nailed bug finally --- ucapi/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 6576cea..d1a3367 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -39,6 +39,7 @@ SearchMediaMsgData, SearchOptions, WsMsgEvents, + SearchResults, ) from .entities import Entities from .entity import EntityTypes @@ -1031,7 +1032,7 @@ async def _search_media( paging=data.paging, ) ) - if isinstance(result, BrowseResults): + if isinstance(result, SearchResults): await self._send_ws_response( websocket, req_id, @@ -1043,7 +1044,7 @@ async def _search_media( await self.acknowledge_command(websocket, req_id, result) except TypeError: _LOG.error( - "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data ) await self.acknowledge_command( websocket, req_id, uc.StatusCodes.BAD_REQUEST From 4c16fbf3b95db29f7092bba0d96ad449487c84d7 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:26:55 +0100 Subject: [PATCH 27/43] Fixes reported by Jack --- ucapi/api.py | 6 ++---- ucapi/api_definitions.py | 10 +++++----- ucapi/media_player.py | 5 +++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index d1a3367..7807f1b 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -34,12 +34,10 @@ BrowseMediaMsgData, BrowseOptions, BrowseResults, - PagingOptions, - SearchMediaFilter, SearchMediaMsgData, SearchOptions, - WsMsgEvents, SearchResults, + WsMsgEvents, ) from .entities import Entities from .entity import EntityTypes @@ -1036,7 +1034,7 @@ async def _search_media( await self._send_ws_response( websocket, req_id, - WsMsgEvents.MEDIA_BROWSE, + WsMsgEvents.MEDIA_SEARCH, asdict(result), StatusCodes.OK, ) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index e375904..d0690f7 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass -from enum import Enum, IntEnum +from enum import Enum, IntEnum, StrEnum from typing import Any, Awaitable, Callable, TypeAlias @@ -360,7 +360,7 @@ class AssistantEvent: data: AssistantEventData | None = None -class MediaContentType(str, Enum): +class MediaContentType(StrEnum): """Media content types for media browsing.""" ALBUM = "album" @@ -386,7 +386,7 @@ class MediaContentType(str, Enum): VIDEO = "video" -class MediaClass(str, Enum): +class MediaClass(StrEnum): """Media classes for media browsing.""" ALBUM = "album" @@ -529,7 +529,7 @@ class BrowseMediaMsgData(BrowseOptions): entity_id: str - def __post_init__(self): + def __post_init__(self): # pylint: disable=W0246 super().__post_init__() @@ -595,7 +595,7 @@ class BrowseResults: @dataclass class SearchResults: """ - Browsing media results. + Searching media results. Attributes: media (list[BrowseMediaItem]): diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 4ed3e32..825c898 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -6,7 +6,7 @@ """ import logging -from enum import Enum +from enum import Enum, StrEnum from typing import Any from .api_definitions import ( @@ -83,6 +83,7 @@ class Features(str, Enum): BROWSE_MEDIA = "browse_media" SEARCH_MEDIA = "search_media" SEARCH_MEDIA_CLASSES = "search_media_classes" + PLAY_MEDIA_ACTION = "play_media_action" class Attributes(str, Enum): @@ -204,7 +205,7 @@ class RepeatMode(str, Enum): ONE = "ONE" -class MediaPlayAction(str, Enum): +class MediaPlayAction(StrEnum): """Media Play actions.""" PLAY_NOW = "PLAY_NOW" From d11cfea3fae125994e0f0187b0df13e5e14801ec Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:54:45 +0100 Subject: [PATCH 28/43] Fixes following Markus review --- ucapi/api_definitions.py | 6 +++++- ucapi/media_player.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/ucapi/api_definitions.py b/ucapi/api_definitions.py index d0690f7..2673b46 100644 --- a/ucapi/api_definitions.py +++ b/ucapi/api_definitions.py @@ -403,6 +403,7 @@ class MediaClass(StrEnum): MUSIC = "music" PLAYLIST = "playlist" PODCAST = "podcast" + RADIO = "radio" SEASON = "season" TRACK = "track" TV_SHOW = "tv_show" @@ -592,6 +593,9 @@ class BrowseResults: pagination: Pagination +SearchMediaItem = BrowseMediaItem + + @dataclass class SearchResults: """ @@ -604,5 +608,5 @@ class SearchResults: Pagination metadata for this result page. """ - media: list[BrowseMediaItem] + media: list[SearchMediaItem] pagination: Pagination diff --git a/ucapi/media_player.py b/ucapi/media_player.py index 825c898..f09881f 100644 --- a/ucapi/media_player.py +++ b/ucapi/media_player.py @@ -106,6 +106,10 @@ class Attributes(str, Enum): SOURCE_LIST = "source_list" SOUND_MODE = "sound_mode" SOUND_MODE_LIST = "sound_mode_list" + MEDIA_ID = "media_id" + MEDIA_PLAYLIST = "media_playlist" + PLAY_MEDIA_ACTION = "play_media_action" + SEARCH_MEDIA_CLASSES = "search_media_classes" class Commands(str, Enum): @@ -168,6 +172,7 @@ class Commands(str, Enum): SETTINGS = "settings" SEARCH = "search" PLAY_MEDIA = "play_media" + CLEAR_PLAYLIST = "clear_playlist" class DeviceClasses(str, Enum): From 2f0259393302559cc12912faeb83627e3f84bb55 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Mar 2026 10:04:28 +0100 Subject: [PATCH 29/43] merge cleanup --- ucapi/api.py | 1 - ucapi/entity.py | 38 -------------------------------------- 2 files changed, 39 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 1d0b742..8d71db3 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -28,7 +28,6 @@ from zeroconf import IPVersion from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf -from . import StatusCodes from . import api_definitions as uc from .api_definitions import WsMsgEvents from .entities import Entities diff --git a/ucapi/entity.py b/ucapi/entity.py index 8c0f742..c4fcfa5 100644 --- a/ucapi/entity.py +++ b/ucapi/entity.py @@ -165,41 +165,3 @@ async def command( return await handler(self, cmd_id, params, websocket=websocket) return await handler(self, cmd_id, params) - - # pylint: disable=W0613 - async def browse_media( - self, - params: dict[str, Any], - *, - websocket: Any, - ) -> dict[str, Any] | StatusCodes: - """ - Execute entity browsing request. - - Returns NOT_IMPLEMENTED if no handler is installed. - - :param params: browsing parameters - :param websocket: optional websocket connection. Allows for directed event - callbacks instead of broadcasts. - :return: browsing response or status code if any error occurs - """ - return StatusCodes.NOT_IMPLEMENTED - - # pylint: disable=W0613 - async def search_media( - self, - params: dict[str, Any], - *, - websocket: Any, - ) -> dict[str, Any] | StatusCodes: - """ - Execute media search request. - - Returns NOT_IMPLEMENTED if no handler is installed. - - :param params: search parameters - :param websocket: optional websocket connection. Allows for directed event - callbacks instead of broadcasts. - :return: search response or status code if any error occurs - """ - return StatusCodes.NOT_IMPLEMENTED From ab21f2233047ca62f59fe15128144b3e571ff1f1 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Mar 2026 10:09:32 +0100 Subject: [PATCH 30/43] clean up comments --- ucapi/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 8d71db3..f08abba 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -223,7 +223,7 @@ async def _start_web_socket_server(self, host: str, port: int) -> None: async def _handle_ws(self, websocket) -> None: try: self._clients.add(websocket) - # Init per-websocket pending requests map + send lock + # Init per-websocket pending requests map self._ws_pending[websocket] = {} _LOG.info("WS: Client added: %s", websocket.remote_address) @@ -492,7 +492,6 @@ async def _ws_request( if websocket not in self._ws_pending: self._ws_pending[websocket] = {} - # Allocate req_id safely req_id = self._req_id self._req_id += 1 From f83a0f152788446ef78072b77425ef8196e500d4 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Mar 2026 12:38:48 +0100 Subject: [PATCH 31/43] no task required for _process_ws_binary_message --- ucapi/api.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index f08abba..30fa788 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -238,10 +238,8 @@ async def _handle_ws(self, websocket) -> None: # JSON text message asyncio.create_task(self._process_ws_message(websocket, message)) elif isinstance(message, (bytes, bytearray, memoryview)): - # Binary message (protobuf in future) - asyncio.create_task( - self._process_ws_binary_message(websocket, bytes(message)) - ) + # Binary message (protobuf) + await self._process_ws_binary_message(websocket, bytes(message)) else: _LOG.warning( "[%s] WS: Unsupported message type %s", From b9d3012bfbb06c4ce7e0df9ce72532ac6ee5fdad Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Fri, 27 Mar 2026 12:39:44 +0100 Subject: [PATCH 32/43] clean up: remove _supported_entity_types --- ucapi/api.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 30fa788..0bff9c7 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -119,8 +119,6 @@ def __init__(self, loop: AbstractEventLoop | None = None): # One receiver per websocket (already in _handle_ws). Responses are dispatched to futures here. self._ws_pending: dict[Any, dict[int, asyncio.Future]] = {} - self._supported_entity_types: list[str] | None = None - # Setup event loop asyncio.set_event_loop(self._loop) @@ -810,15 +808,6 @@ async def _handle_ws_request_msg( ) elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: available_entities = self._available_entities.get_all() - # if self._supported_entity_types is None: - # # Request supported entity types from remote - # await self._update_supported_entity_types(websocket) - # if self._supported_entity_types: - # available_entities = [ - # entity - # for entity in available_entities - # if entity.get("entity_type") in self._supported_entity_types - # ] await self._send_ws_response( websocket, req_id, @@ -1486,27 +1475,6 @@ async def get_supported_entity_types( ) return resp.get("msg_data", []) - async def _update_supported_entity_types( - self, websocket, *, timeout: float = 5.0 - ) -> None: - """Update supported entity types by remote.""" - await asyncio.sleep(0) - try: - self._supported_entity_types = await self.get_supported_entity_types( - websocket, timeout=timeout - ) - _LOG.debug( - "[%s] Supported entity types %s", - websocket.remote_address, - self._supported_entity_types, - ) - except Exception as ex: # pylint: disable=W0718 - _LOG.error( - "[%s] Unable to retrieve entity types %s", - websocket.remote_address, - ex, - ) - async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any]: """Request client version and return msg_data.""" resp = await self._ws_request( From 9c41ae7d002ac287559f4ac641744f2af5feb845 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:40:35 +0200 Subject: [PATCH 33/43] Changed code with queues management --- ucapi/api.py | 280 +++++++++++++++++++++++++++++---------------------- 1 file changed, 158 insertions(+), 122 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 0ab785e..eb50b77 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -78,6 +78,18 @@ class _VoiceSessionContext: handler_task: asyncio.Task | None = None +@dataclass(slots=True) +class _WsContext: + """Websocket context.""" + + incoming: asyncio.Queue[str | bytes | None] + outgoing: asyncio.Queue[str | None] + pending: dict[int, asyncio.Future] + consumer_task: asyncio.Task | None = None + producer_task: asyncio.Task | None = None + router_task: asyncio.Task | None = None + + # pylint: disable=too-many-public-methods, too-many-lines class IntegrationAPI: """Integration API to communicate with Remote Two/3.""" @@ -115,11 +127,8 @@ def __init__(self, loop: AbstractEventLoop | None = None): self._voice_sessions: dict[VoiceSessionKey, _VoiceSessionContext] = {} # Enforce: at most one active session per entity_id (across all websockets) self._voice_session_by_entity: dict[str, VoiceSessionKey] = {} - - # One receiver per websocket (already in _handle_ws). Responses are dispatched to futures here. - self._ws_pending: dict[Any, dict[int, asyncio.Future]] = {} - - self._supported_entity_types: list[str] | None = None + # Websocket context with incoming & outgoing queues and handlers + self._ws_contexts: dict[Any, _WsContext] = {} # Setup event loop asyncio.set_event_loop(self._loop) @@ -221,42 +230,65 @@ async def _start_web_socket_server(self, host: str, port: int) -> None: await asyncio.Future() async def _handle_ws(self, websocket) -> None: + # Initialize incoming and outgoing queues + incoming: asyncio.Queue[str | bytes | None] = asyncio.Queue(maxsize=100) + outgoing: asyncio.Queue[str | None] = asyncio.Queue(maxsize=100) + + ctx = _WsContext( + incoming=incoming, + outgoing=outgoing, + pending={}, + ) + + self._clients.add(websocket) + self._ws_contexts[websocket] = ctx + try: - self._clients.add(websocket) - # Init per-websocket pending requests map + send lock - self._ws_pending[websocket] = {} _LOG.info("WS: Client added: %s", websocket.remote_address) + ctx.consumer_task = self._loop.create_task( + self._ws_consumer(websocket, ctx) + ) + ctx.producer_task = self._loop.create_task( + self._ws_producer(websocket, ctx) + ) + ctx.router_task = self._loop.create_task(self._ws_router(websocket, ctx)) + # authenticate on connection await self._authenticate(websocket, True) - self._events.emit(uc.Events.CLIENT_CONNECTED, websocket=websocket) + tasks = [ + t + for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] + if t is not None + ] + done, pending = await asyncio.wait( + tasks, + return_when=asyncio.FIRST_COMPLETED, + ) - async for message in websocket: - # Distinguish between text (str) and binary (bytes-like) messages - if isinstance(message, str): - # JSON text message - await self._process_ws_message(websocket, message) - elif isinstance(message, (bytes, bytearray, memoryview)): - # Binary message (protobuf in future) - await self._process_ws_binary_message(websocket, bytes(message)) - else: - _LOG.warning( - "[%s] WS: Unsupported message type %s", - websocket.remote_address, - type(message).__name__, - ) + for task in pending: + task.cancel() + + results = await asyncio.gather(*done, *pending, return_exceptions=True) + for result in results: + if isinstance(result, Exception) and not isinstance( + result, asyncio.CancelledError + ): + raise result except ConnectionClosedOK: _LOG.info("[%s] WS: Connection closed", websocket.remote_address) except websockets.exceptions.ConnectionClosedError as e: - # no idea why they made code & reason deprecated... + close = e.rcvd or e.sent + code = getattr(close, "code", None) + reason = getattr(close, "reason", None) _LOG.info( - "[%s] WS: Connection closed with error %d: %s", + "[%s] WS: Connection closed with error %s: %s", websocket.remote_address, - e.code, - e.reason, + code, + reason, ) except websockets.exceptions.WebSocketException as e: @@ -267,26 +299,60 @@ async def _handle_ws(self, websocket) -> None: ) finally: - # Cleanup any active voice sessions associated with this websocket - keys_to_cleanup = [k for k in self._voice_sessions if k[0] is websocket] - for key in keys_to_cleanup: - try: - await self._cleanup_voice_session(key, VoiceEndReason.REMOTE) - except Exception as ex: # pylint: disable=W0718 - _LOG.exception( - "[%s] WS: Error during voice session cleanup for session_id=%s: %s", - websocket.remote_address, - key[1], - ex, - ) - # Cancel all pending requests for this websocket (client disconnected) - pending = self._ws_pending.pop(websocket, {}) - for _, fut in pending.items(): - if not fut.done(): - fut.set_exception(ConnectionError("WebSocket disconnected")) - self._clients.remove(websocket) - _LOG.info("[%s] WS: Client removed", websocket.remote_address) - self._events.emit(uc.Events.CLIENT_DISCONNECTED, websocket=websocket) + await self._cleanup_ws(websocket) + + async def _ws_consumer(self, websocket, ctx: _WsContext) -> None: + try: + async for message in websocket: + await ctx.incoming.put(message) + finally: + await ctx.incoming.put(None) + await ctx.outgoing.put(None) + + async def _ws_producer(self, websocket, ctx: _WsContext) -> None: + try: + while True: + msg = await ctx.outgoing.get() + if msg is None: + break + await websocket.send(msg) + except (ConnectionClosedOK, websockets.exceptions.ConnectionClosedError): + pass + + async def _ws_router(self, websocket, ctx: _WsContext) -> None: + while True: + message = await ctx.incoming.get() + if message is None: + break + # Distinguish between text (str) and binary (bytes-like) messages + if isinstance(message, str): + # JSON text message + await self._process_ws_message(websocket, message) + elif isinstance(message, (bytes, bytearray, memoryview)): + # Binary message (protobuf in future) + await self._process_ws_binary_message(websocket, bytes(message)) + else: + _LOG.warning( + "[%s] WS: Unsupported message type %s", + websocket.remote_address, + type(message).__name__, + ) + + def _get_ws_context(self, websocket) -> _WsContext | None: + return self._ws_contexts.get(websocket) + + async def _enqueue_ws_payload(self, websocket, payload: dict[str, Any]) -> None: + ctx = self._get_ws_context(websocket) + if ctx is None or websocket not in self._clients: + _LOG.error("Error sending payload: connection no longer established") + return + + if _LOG.isEnabledFor(logging.DEBUG): + _LOG.debug( + "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload) + ) + + await ctx.outgoing.put(json.dumps(payload)) async def _send_ok_result( self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None @@ -325,7 +391,6 @@ async def _send_error_result( """ await self._send_ws_response(websocket, req_id, "result", msg_data, status_code) - # pylint: disable=R0917 async def _send_ws_response( self, websocket, @@ -353,16 +418,7 @@ async def _send_ws_response( "msg": msg, "msg_data": msg_data if msg_data is not None else {}, } - - if websocket in self._clients: - data_dump = json.dumps(data) - if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(data) - ) - await websocket.send(data_dump) - else: - _LOG.error("Error sending response: connection no longer established") + await self._enqueue_ws_payload(websocket, data) async def _broadcast_ws_event( self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory @@ -378,17 +434,13 @@ async def _broadcast_ws_event( :param category: event category """ data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} - data_dump = json.dumps(data) - for websocket in self._clients.copy(): - if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] =>: %s", websocket.remote_address, filter_log_msg_data(data) - ) try: - await websocket.send(data_dump) - except websockets.exceptions.WebSocketException: - pass + await self._enqueue_ws_payload(websocket, data) + except Exception: + _LOG.exception( + "Failed to enqueue broadcast for %s", websocket.remote_address + ) async def _send_ws_event( self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory @@ -405,16 +457,7 @@ async def _send_ws_event( websockets.ConnectionClosed: When the connection is closed. """ data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} - data_dump = json.dumps(data) - - if websocket in self._clients: - if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(data) - ) - await websocket.send(data_dump) - else: - _LOG.error("Error sending event: connection no longer established") + await self._enqueue_ws_payload(websocket, data) async def _process_ws_message(self, websocket, message) -> None: _LOG.debug("[%s] <-: %s", websocket.remote_address, message) @@ -445,16 +488,11 @@ async def _process_ws_message(self, websocket, message) -> None: message, ) return - - pending = self._ws_pending.get(websocket) - if not pending: - _LOG.debug( - "[%s] WS: No pending map for resp_id=%s (late resp?)", - websocket.remote_address, - resp_id, - ) + ctx = self._get_ws_context(websocket) + if ctx is None: + _LOG.debug("[%s] WS: No context for resp", websocket.remote_address) return - fut = pending.get(int(resp_id)) + fut = ctx.pending.get(int(resp_id)) if fut is None: _LOG.debug( "[%s] WS: Unmatched resp_id=%s (not pending). msg=%s", @@ -487,30 +525,25 @@ async def _ws_request( :param timeout: timeout for message """ # Ensure per-socket structures exist (in case you call before _handle_ws init) - if websocket not in self._ws_pending: - self._ws_pending[websocket] = {} + ctx = self._get_ws_context(websocket) + if ctx is None: + raise ConnectionError("WebSocket context not found") # Allocate req_id safely req_id = self._req_id self._req_id += 1 fut = self._loop.create_future() - self._ws_pending[websocket][req_id] = fut + ctx.pending[req_id] = fut try: payload: dict[str, Any] = {"kind": "req", "id": req_id, "msg": msg} if msg_data is not None: payload["msg_data"] = msg_data - if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] ->: %s", - websocket.remote_address, - filter_log_msg_data(payload), - ) - await websocket.send(json.dumps(payload)) + await self._enqueue_ws_payload(websocket, payload) - # Await response + # Await response from client until given timeout resp = await asyncio.wait_for(fut, timeout=timeout) return resp @@ -525,9 +558,7 @@ async def _ws_request( raise ex finally: # Cleanup pending future entry - pending = self._ws_pending.get(websocket) - if pending: - pending.pop(req_id, None) + ctx.pending.pop(req_id, None) async def _process_ws_binary_message(self, websocket, data: bytes) -> None: """Process a binary WebSocket message using protobuf IntegrationMessage. @@ -569,6 +600,30 @@ async def _process_ws_binary_message(self, websocket, data: bytes) -> None: kind, ) + async def _cleanup_ws(self, websocket) -> None: + ctx = self._ws_contexts.pop(websocket, None) + + keys_to_cleanup = [k for k in self._voice_sessions if k[0] is websocket] + for key in keys_to_cleanup: + try: + await self._cleanup_voice_session(key, VoiceEndReason.REMOTE) + except Exception as ex: + _LOG.exception( + "[%s] WS: Error during voice session cleanup for session_id=%s: %s", + websocket.remote_address, + key[1], + ex, + ) + + if ctx is not None: + for fut in ctx.pending.values(): + if not fut.done(): + fut.set_exception(ConnectionError("WebSocket disconnected")) + + self._clients.discard(websocket) + _LOG.info("[%s] WS: Client removed", websocket.remote_address) + self._events.emit(uc.Events.CLIENT_DISCONNECTED, websocket=websocket) + async def _on_remote_voice_begin(self, websocket, msg: RemoteVoiceBegin) -> None: """Handle a RemoteVoiceBegin protobuf message. @@ -1475,28 +1530,9 @@ async def get_supported_entity_types( ) return resp.get("msg_data", []) - async def _update_supported_entity_types( + async def get_version( self, websocket, *, timeout: float = 5.0 - ) -> None: - """Update supported entity types by remote.""" - await asyncio.sleep(0) - try: - self._supported_entity_types = await self.get_supported_entity_types( - websocket, timeout=timeout - ) - _LOG.debug( - "[%s] Supported entity types %s", - websocket.remote_address, - self._supported_entity_types, - ) - except Exception as ex: # pylint: disable=W0718 - _LOG.error( - "[%s] Unable to retrieve entity types %s", - websocket.remote_address, - ex, - ) - - async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any]: + ) -> dict[str, Any] | None: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1514,7 +1550,7 @@ async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any async def get_localization_cfg( self, websocket, *, timeout: float = 5.0 - ) -> dict[str, Any]: + ) -> dict[str, Any] | None: """Request localization config and return msg_data.""" resp = await self._ws_request( websocket, From c47b9eff059ed4f25dbbae1a0939aaab0cda7a96 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:42:51 +0200 Subject: [PATCH 34/43] Removed unecessary files --- .idea/.gitignore | 8 ----- .idea/codeStyles/Project.xml | 7 ---- .idea/codeStyles/codeStyleConfig.xml | 5 --- .idea/integration-python-library.iml | 15 -------- .idea/misc.xml | 13 ------- .idea/modules.xml | 8 ----- .idea/runConfigurations/Check_Black_style.xml | 35 ------------------ .idea/runConfigurations/Check_flake8.xml | 34 ------------------ .idea/runConfigurations/Check_isort.xml | 36 ------------------- .idea/runConfigurations/Check_pylint.xml | 34 ------------------ .../runConfigurations/Format_Black_style.xml | 35 ------------------ .idea/runConfigurations/Run_isort.xml | 36 ------------------- .idea/runConfigurations/Unit_tests.xml | 28 --------------- .idea/vcs.xml | 13 ------- 14 files changed, 307 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/codeStyles/Project.xml delete mode 100644 .idea/codeStyles/codeStyleConfig.xml delete mode 100644 .idea/integration-python-library.iml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/runConfigurations/Check_Black_style.xml delete mode 100644 .idea/runConfigurations/Check_flake8.xml delete mode 100644 .idea/runConfigurations/Check_isort.xml delete mode 100644 .idea/runConfigurations/Check_pylint.xml delete mode 100644 .idea/runConfigurations/Format_Black_style.xml delete mode 100644 .idea/runConfigurations/Run_isort.xml delete mode 100644 .idea/runConfigurations/Unit_tests.xml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index df77493..0000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/integration-python-library.iml b/.idea/integration-python-library.iml deleted file mode 100644 index 0381ed4..0000000 --- a/.idea/integration-python-library.iml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 10c0840..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cc371ca..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Check_Black_style.xml b/.idea/runConfigurations/Check_Black_style.xml deleted file mode 100644 index 229d497..0000000 --- a/.idea/runConfigurations/Check_Black_style.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Check_flake8.xml b/.idea/runConfigurations/Check_flake8.xml deleted file mode 100644 index 5a19db2..0000000 --- a/.idea/runConfigurations/Check_flake8.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Check_isort.xml b/.idea/runConfigurations/Check_isort.xml deleted file mode 100644 index 9289f18..0000000 --- a/.idea/runConfigurations/Check_isort.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Check_pylint.xml b/.idea/runConfigurations/Check_pylint.xml deleted file mode 100644 index 852aeaa..0000000 --- a/.idea/runConfigurations/Check_pylint.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Format_Black_style.xml b/.idea/runConfigurations/Format_Black_style.xml deleted file mode 100644 index 5ebef65..0000000 --- a/.idea/runConfigurations/Format_Black_style.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Run_isort.xml b/.idea/runConfigurations/Run_isort.xml deleted file mode 100644 index eb81aae..0000000 --- a/.idea/runConfigurations/Run_isort.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Unit_tests.xml b/.idea/runConfigurations/Unit_tests.xml deleted file mode 100644 index d814835..0000000 --- a/.idea/runConfigurations/Unit_tests.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index a081b18..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file From 3fb765e948420d1a8cf2cf29536c841d7ff1c265 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:44:24 +0200 Subject: [PATCH 35/43] Reverted unwanted changes --- .idea/.gitignore | 8 +++++ .idea/codeStyles/Project.xml | 7 ++++ .idea/codeStyles/codeStyleConfig.xml | 5 +++ .idea/inspectionProfiles/Project_Default.xml | 23 ++++++++++++ .idea/integration-python-library.iml | 14 ++++++++ .idea/misc.xml | 10 ++++++ .idea/modules.xml | 8 +++++ .idea/runConfigurations/Check_Black_style.xml | 35 ++++++++++++++++++ .idea/runConfigurations/Check_flake8.xml | 34 ++++++++++++++++++ .idea/runConfigurations/Check_isort.xml | 36 +++++++++++++++++++ .idea/runConfigurations/Check_pylint.xml | 34 ++++++++++++++++++ .../runConfigurations/Format_Black_style.xml | 35 ++++++++++++++++++ .idea/runConfigurations/Run_isort.xml | 36 +++++++++++++++++++ .idea/runConfigurations/Unit_tests.xml | 28 +++++++++++++++ .idea/vcs.xml | 13 +++++++ 15 files changed, 326 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/integration-python-library.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/runConfigurations/Check_Black_style.xml create mode 100644 .idea/runConfigurations/Check_flake8.xml create mode 100644 .idea/runConfigurations/Check_isort.xml create mode 100644 .idea/runConfigurations/Check_pylint.xml create mode 100644 .idea/runConfigurations/Format_Black_style.xml create mode 100644 .idea/runConfigurations/Run_isort.xml create mode 100644 .idea/runConfigurations/Unit_tests.xml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..df77493 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..c378e9d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,23 @@ + + + + \ No newline at end of file diff --git a/.idea/integration-python-library.iml b/.idea/integration-python-library.iml new file mode 100644 index 0000000..bbb1500 --- /dev/null +++ b/.idea/integration-python-library.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8711418 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..cc371ca --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check_Black_style.xml b/.idea/runConfigurations/Check_Black_style.xml new file mode 100644 index 0000000..229d497 --- /dev/null +++ b/.idea/runConfigurations/Check_Black_style.xml @@ -0,0 +1,35 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check_flake8.xml b/.idea/runConfigurations/Check_flake8.xml new file mode 100644 index 0000000..5a19db2 --- /dev/null +++ b/.idea/runConfigurations/Check_flake8.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check_isort.xml b/.idea/runConfigurations/Check_isort.xml new file mode 100644 index 0000000..9289f18 --- /dev/null +++ b/.idea/runConfigurations/Check_isort.xml @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check_pylint.xml b/.idea/runConfigurations/Check_pylint.xml new file mode 100644 index 0000000..852aeaa --- /dev/null +++ b/.idea/runConfigurations/Check_pylint.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Format_Black_style.xml b/.idea/runConfigurations/Format_Black_style.xml new file mode 100644 index 0000000..5ebef65 --- /dev/null +++ b/.idea/runConfigurations/Format_Black_style.xml @@ -0,0 +1,35 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Run_isort.xml b/.idea/runConfigurations/Run_isort.xml new file mode 100644 index 0000000..eb81aae --- /dev/null +++ b/.idea/runConfigurations/Run_isort.xml @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Unit_tests.xml b/.idea/runConfigurations/Unit_tests.xml new file mode 100644 index 0000000..d814835 --- /dev/null +++ b/.idea/runConfigurations/Unit_tests.xml @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..a081b18 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file From 8ca4a4a16e4cebaa84c485c7519716f4296a93e7 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:45:07 +0200 Subject: [PATCH 36/43] .. --- .idea/inspectionProfiles/Project_Default.xml | 23 -------------------- 1 file changed, 23 deletions(-) delete mode 100644 .idea/inspectionProfiles/Project_Default.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index c378e9d..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - \ No newline at end of file From 48d6aeea531203a880e9a2811a3fc4cc905a48f5 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:30:00 +0200 Subject: [PATCH 37/43] Handle of requests/response inside requests --- ucapi/api.py | 439 +++++++++++++++++++++------------------------------ 1 file changed, 182 insertions(+), 257 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index eb50b77..6cf76f0 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -129,6 +129,8 @@ def __init__(self, loop: AbstractEventLoop | None = None): self._voice_session_by_entity: dict[str, VoiceSessionKey] = {} # Websocket context with incoming & outgoing queues and handlers self._ws_contexts: dict[Any, _WsContext] = {} + # Supported entity types + self._supported_entity_types: list[str] | None = None # Setup event loop asyncio.set_event_loop(self._loop) @@ -141,9 +143,7 @@ def _resolve_config_dir() -> str: def _voice_key(websocket: Any, session_id: int) -> VoiceSessionKey: return websocket, int(session_id) - async def init( - self, driver_path: str, setup_handler: uc.SetupHandler | None = None - ): + async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = None): """ Load driver configuration and start integration-API WebSocket server. @@ -154,9 +154,7 @@ async def init( self._driver_path = driver_path self._setup_handler = setup_handler - self._configured_entities.add_listener( - uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated - ) + self._configured_entities.add_listener(uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: @@ -171,17 +169,13 @@ async def init( _adjust_driver_url(self._driver_info, port) - disable_mdns_publish = os.getenv( - "UC_DISABLE_MDNS_PUBLISH", "false" - ).lower() in ("true", "1") + disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") if disable_mdns_publish is False: # Setup zeroconf service info name = f'{self._driver_info["driver_id"]}._uc-integration._tcp.local.' hostname = local_hostname() - driver_name = _get_default_language_string( - self._driver_info["name"], "Unknown driver" - ) + driver_name = _get_default_language_string(self._driver_info["name"], "Unknown driver") _LOG.debug("Publishing driver: name=%s, host=%s:%d", name, hostname, port) @@ -201,9 +195,7 @@ async def init( await zeroconf.async_register_service(info) host = interface if interface is not None else "0.0.0.0" - self._server_task = self._loop.create_task( - self._start_web_socket_server(host, port) - ) + self._server_task = self._loop.create_task(self._start_web_socket_server(host, port)) _LOG.info( "Driver is up: %s, version: %s, api: %s, listening on: %s:%d", @@ -221,9 +213,7 @@ async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes "attributes": attributes, } - await self._broadcast_ws_event( - uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY - ) + await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) async def _start_web_socket_server(self, host: str, port: int) -> None: async with serve(self._handle_ws, host, port): @@ -246,22 +236,14 @@ async def _handle_ws(self, websocket) -> None: try: _LOG.info("WS: Client added: %s", websocket.remote_address) - ctx.consumer_task = self._loop.create_task( - self._ws_consumer(websocket, ctx) - ) - ctx.producer_task = self._loop.create_task( - self._ws_producer(websocket, ctx) - ) + ctx.consumer_task = self._loop.create_task(self._ws_consumer(websocket, ctx)) + ctx.producer_task = self._loop.create_task(self._ws_producer(websocket, ctx)) ctx.router_task = self._loop.create_task(self._ws_router(websocket, ctx)) # authenticate on connection await self._authenticate(websocket, True) self._events.emit(uc.Events.CLIENT_CONNECTED, websocket=websocket) - tasks = [ - t - for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] - if t is not None - ] + tasks = [t for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] if t is not None] done, pending = await asyncio.wait( tasks, return_when=asyncio.FIRST_COMPLETED, @@ -272,9 +254,7 @@ async def _handle_ws(self, websocket) -> None: results = await asyncio.gather(*done, *pending, return_exceptions=True) for result in results: - if isinstance(result, Exception) and not isinstance( - result, asyncio.CancelledError - ): + if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): raise result except ConnectionClosedOK: @@ -302,14 +282,43 @@ async def _handle_ws(self, websocket) -> None: await self._cleanup_ws(websocket) async def _ws_consumer(self, websocket, ctx: _WsContext) -> None: + """Route incoming message (requests or events from remote or responses to driver).""" try: - async for message in websocket: - await ctx.incoming.put(message) + async for raw_message in websocket: + if isinstance(raw_message, str): + try: + data = json.loads(raw_message) + except json.JSONDecodeError: + _LOG.warning( + "[%s] WS: Invalid JSON message: %s", + websocket.remote_address, + raw_message, + ) + continue + + kind = data.get("kind") + + # Handle the response to a previous driver request + if kind == "resp": + self._handle_pending_response(websocket, data) + # Otherwise handle the json request + else: + await ctx.incoming.put(data) + # Handle the binary message + elif isinstance(raw_message, (bytes, bytearray, memoryview)): + await ctx.incoming.put(bytes(raw_message)) + else: + _LOG.warning( + "[%s] WS: Unsupported message type %s", + websocket.remote_address, + type(raw_message).__name__, + ) finally: await ctx.incoming.put(None) await ctx.outgoing.put(None) async def _ws_producer(self, websocket, ctx: _WsContext) -> None: + """Route outgoing messages.""" try: while True: msg = await ctx.outgoing.get() @@ -320,20 +329,18 @@ async def _ws_producer(self, websocket, ctx: _WsContext) -> None: pass async def _ws_router(self, websocket, ctx: _WsContext) -> None: + """Route incoming requests.""" while True: message = await ctx.incoming.get() if message is None: break - # Distinguish between text (str) and binary (bytes-like) messages - if isinstance(message, str): - # JSON text message + if isinstance(message, dict): await self._process_ws_message(websocket, message) - elif isinstance(message, (bytes, bytearray, memoryview)): - # Binary message (protobuf in future) - await self._process_ws_binary_message(websocket, bytes(message)) + elif isinstance(message, bytes): + await self._process_ws_binary_message(websocket, message) else: _LOG.warning( - "[%s] WS: Unsupported message type %s", + "[%s] WS: Unsupported routed message type %s", websocket.remote_address, type(message).__name__, ) @@ -348,15 +355,11 @@ async def _enqueue_ws_payload(self, websocket, payload: dict[str, Any]) -> None: return if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload) - ) + _LOG.debug("[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload)) await ctx.outgoing.put(json.dumps(payload)) - async def _send_ok_result( - self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None - ) -> None: + async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None) -> None: """ Send a WebSocket success message with status code OK. @@ -367,9 +370,7 @@ async def _send_ok_result( Raises: websockets.ConnectionClosed: When the connection is closed. """ - await self._send_ws_response( - websocket, req_id, "result", msg_data, uc.StatusCodes.OK - ) + await self._send_ws_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) async def _send_error_result( self, @@ -420,9 +421,7 @@ async def _send_ws_response( } await self._enqueue_ws_payload(websocket, data) - async def _broadcast_ws_event( - self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory - ) -> None: + async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: """ Send the given event-message to all connected WebSocket clients. @@ -438,13 +437,9 @@ async def _broadcast_ws_event( try: await self._enqueue_ws_payload(websocket, data) except Exception: - _LOG.exception( - "Failed to enqueue broadcast for %s", websocket.remote_address - ) + _LOG.exception("Failed to enqueue broadcast for %s", websocket.remote_address) - async def _send_ws_event( - self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory - ) -> None: + async def _send_ws_event(self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: """ Send an event-message to the given WebSocket client. @@ -459,51 +454,60 @@ async def _send_ws_event( data = {"kind": "event", "msg": msg, "msg_data": msg_data, "cat": category} await self._enqueue_ws_payload(websocket, data) - async def _process_ws_message(self, websocket, message) -> None: - _LOG.debug("[%s] <-: %s", websocket.remote_address, message) + async def _process_ws_message(self, websocket, data: dict[str, Any]) -> None: + _LOG.debug("[%s] <-: %s", websocket.remote_address, data) - data = json.loads(message) kind = data["kind"] - req_id = data["id"] if "id" in data else None + req_id = data.get("id") msg = data["msg"] - msg_data = data["msg_data"] if "msg_data" in data else None + msg_data = data.get("msg_data") if kind == "req": if req_id is None: _LOG.warning( - "Ignoring request message with missing 'req_id': %s", message + "Ignoring request message with missing 'id': %s", + data, ) - else: - await self._handle_ws_request_msg(websocket, msg, req_id, msg_data) + return + await self._handle_ws_request_msg(websocket, msg, req_id, msg_data) elif kind == "event": await self._handle_ws_event_msg(websocket, msg, msg_data) - elif kind == "resp": - # Response to a previously sent request - # Some implementations use "req_id", others use "id" - resp_id = data.get("req_id", data.get("id")) - if resp_id is None: - _LOG.warning( - "[%s] WS: Received resp without req_id/id: %s", - websocket.remote_address, - message, - ) - return - ctx = self._get_ws_context(websocket) - if ctx is None: - _LOG.debug("[%s] WS: No context for resp", websocket.remote_address) - return - fut = ctx.pending.get(int(resp_id)) - if fut is None: - _LOG.debug( - "[%s] WS: Unmatched resp_id=%s (not pending). msg=%s", - websocket.remote_address, - resp_id, - msg, - ) - return + else: + _LOG.warning( + "[%s] WS: Unsupported routed message kind %s", + websocket.remote_address, + kind, + ) + + def _handle_pending_response(self, websocket, data: dict[str, Any]) -> None: + """Resolve the response message that corresponds to a pending request from the driver.""" + + resp_id = data.get("req_id", data.get("id")) + if resp_id is None: + _LOG.warning( + "[%s] WS: Received resp without req_id/id: %s", + websocket.remote_address, + data, + ) + return - if not fut.done(): - fut.set_result(data) + ctx = self._get_ws_context(websocket) + if ctx is None: + _LOG.debug("[%s] WS: No context for resp", websocket.remote_address) + return + + fut = ctx.pending.get(int(resp_id)) + if fut is None: + _LOG.debug( + "[%s] WS: Unmatched resp_id=%s (not pending). msg=%s", + websocket.remote_address, + resp_id, + data.get("msg"), + ) + return + + if not fut.done(): + fut.set_result(data) async def _ws_request( self, @@ -568,9 +572,7 @@ async def _process_ws_binary_message(self, websocket, data: bytes) -> None: - Logs errors on deserialization failures and unknown message kinds. """ if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] <-: ", websocket.remote_address, len(data) - ) + _LOG.debug("[%s] <-: ", websocket.remote_address, len(data)) # Parse IntegrationMessage from bytes try: @@ -832,9 +834,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: # If handler not started yet, start it now (best effort) if ctx.handler_task is None and self._voice_handler is not None: try: - ctx.handler_task = self._loop.create_task( - self._run_voice_handler(ctx.session) - ) + ctx.handler_task = self._loop.create_task(self._run_voice_handler(ctx.session)) except Exception: # pylint: disable=W0718 _LOG.exception( "Failed to start voice handler on timeout for session %s", @@ -846,9 +846,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: await self._cleanup_voice_session(key) # pylint: disable=R0912 - async def _handle_ws_request_msg( - self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None) -> None: if msg == uc.WsMessages.GET_DRIVER_VERSION: await self._send_ws_response( websocket, @@ -864,13 +862,7 @@ async def _handle_ws_request_msg( {"state": self.device_state}, ) elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES: - available_entities = self._available_entities.get_all() - await self._send_ws_response( - websocket, - req_id, - uc.WsMsgEvents.AVAILABLE_ENTITIES, - {"available_entities": available_entities}, - ) + await self._get_available_entities(websocket, req_id) elif msg == uc.WsMessages.GET_ENTITY_STATES: entity_states = await self._configured_entities.get_states() await self._send_ws_response( @@ -892,9 +884,7 @@ async def _handle_ws_request_msg( await self._unsubscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) elif msg == uc.WsMessages.GET_DRIVER_METADATA: - await self._send_ws_response( - websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info - ) + await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info) elif msg == uc.WsMessages.SETUP_DRIVER: if not await self._setup_driver(websocket, req_id, msg_data): # sleep for web-configurator quirks... @@ -905,9 +895,7 @@ async def _handle_ws_request_msg( await asyncio.sleep(0.5) await self.driver_setup_error(websocket) - async def _handle_ws_event_msg( - self, websocket: Any, msg: str, msg_data: dict[str, Any] | None - ) -> None: + async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[str, Any] | None) -> None: if msg == uc.WsMsgEvents.CONNECT: self._events.emit(uc.Events.CONNECT, websocket=websocket) elif msg == uc.WsMsgEvents.DISCONNECT: @@ -918,9 +906,7 @@ async def _handle_ws_event_msg( self._events.emit(uc.Events.EXIT_STANDBY, websocket=websocket) elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP: if not self._setup_handler: - _LOG.warning( - "Received abort_driver_setup event, but no setup handler provided by the driver!" - ) # noqa + _LOG.warning("Received abort_driver_setup event, but no setup handler provided by the driver!") # noqa return if "error" in msg_data: @@ -930,9 +916,7 @@ async def _handle_ws_event_msg( error = uc.IntegrationSetupError.OTHER await self._setup_handler(uc.AbortDriverSetup(error)) else: - _LOG.warning( - "Unsupported abort_driver_setup payload received: %s", msg_data - ) + _LOG.warning("Unsupported abort_driver_setup payload received: %s", msg_data) async def _authenticate(self, websocket, success: bool) -> None: await self._send_ws_response( @@ -968,9 +952,7 @@ async def set_device_state(self, state: uc.DeviceStates) -> None: uc.EventCategory.DEVICE, ) - async def _subscribe_events( - self, websocket: Any, msg_data: dict[str, Any] | None - ) -> None: + async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> None: if msg_data is None: _LOG.warning("Ignoring _subscribe_events: called with empty msg_data") return @@ -990,9 +972,7 @@ async def _subscribe_events( websocket=websocket, ) - async def _unsubscribe_events( - self, websocket: Any, msg_data: dict[str, Any] | None - ) -> bool: + async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> bool: if msg_data is None: _LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") return False @@ -1011,23 +991,17 @@ async def _unsubscribe_events( return res - async def _entity_command( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring entity command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None cmd_id = msg_data["cmd_id"] if "cmd_id" in msg_data else None if entity_id is None or cmd_id is None: _LOG.warning("Ignoring command: missing entity_id or cmd_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1090,28 +1064,20 @@ async def _entity_command( "Old Entity.command signature detected for %s, trying old signature. Please update the command signature.", entity.id, ) - result = await entity.command( - cmd_id, msg_data["params"] if "params" in msg_data else None - ) + result = await entity.command(cmd_id, msg_data["params"] if "params" in msg_data else None) await self.acknowledge_command(websocket, req_id, result) - async def _browse_media( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring browse_media command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring browse_media command: missing entity_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1127,12 +1093,8 @@ async def _browse_media( try: data = BrowseMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error( - "Cannot browse media for '%s': wrong format %s", entity_id, msg_data - ) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + _LOG.error("Cannot browse media for '%s': wrong format %s", entity_id, msg_data) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return # call integration driver to handle browse request @@ -1140,9 +1102,7 @@ async def _browse_media( result = await entity.browse(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.browse for '%s'", entity_id) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.SERVER_ERROR - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) return if isinstance(result, BrowseResults): @@ -1156,22 +1116,16 @@ async def _browse_media( else: await self.acknowledge_command(websocket, req_id, result) - async def _search_media( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring search_media command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring search_media command: missing entity_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1186,21 +1140,15 @@ async def _search_media( try: data = SearchMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error( - "Cannot search media for '%s': wrong format %s", entity_id, msg_data - ) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + _LOG.error("Cannot search media for '%s': wrong format %s", entity_id, msg_data) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return try: result = await entity.search(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.search for '%s'", entity_id) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.SERVER_ERROR - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) return if isinstance(result, SearchResults): @@ -1214,9 +1162,7 @@ async def _search_media( else: await self.acknowledge_command(websocket, req_id, result) - async def _setup_driver( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> bool: + async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: await self.acknowledge_command(websocket, req_id) if msg_data is None or "setup_data" not in msg_data: @@ -1226,24 +1172,18 @@ async def _setup_driver( # 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!" - ) # noqa + _LOG.error("Received setup_driver request, but no setup handler provided by the driver!") # noqa return False result = False try: action = await self._setup_handler( - uc.DriverSetupRequest( - msg_data.get("reconfigure") or False, msg_data["setup_data"] - ) + uc.DriverSetupRequest(msg_data.get("reconfigure") or False, msg_data["setup_data"]) ) if isinstance(action, uc.RequestUserInput): await self.driver_setup_progress(websocket) - await self.request_driver_setup_user_input( - websocket, action.title, action.settings - ) + await self.request_driver_setup_user_input(websocket, action.title, action.settings) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.driver_setup_progress(websocket) @@ -1264,15 +1204,11 @@ async def _setup_driver( return result - async def _set_driver_user_data( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> bool: + async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: await self.acknowledge_command(websocket, req_id) if not self._setup_handler: - _LOG.error( - "Received set_driver_user_data request, but no setup handler provided by the driver!" - ) # noqa + _LOG.error("Received set_driver_user_data request, but no setup handler provided by the driver!") # noqa return False if "input_values" in msg_data or "confirm" in msg_data: @@ -1280,27 +1216,19 @@ async def _set_driver_user_data( await asyncio.sleep(0.5) await self.driver_setup_progress(websocket) else: - _LOG.warning( - "Unsupported set_driver_user_data payload received: %s", msg_data - ) + _LOG.warning("Unsupported set_driver_user_data payload received: %s", msg_data) return False result = False try: action = uc.SetupError() if "input_values" in msg_data: - action = await self._setup_handler( - uc.UserDataResponse(msg_data["input_values"]) - ) + action = await self._setup_handler(uc.UserDataResponse(msg_data["input_values"])) elif "confirm" in msg_data: - action = await self._setup_handler( - uc.UserConfirmationResponse(msg_data["confirm"]) - ) + action = await self._setup_handler(uc.UserConfirmationResponse(msg_data["confirm"])) if isinstance(action, uc.RequestUserInput): - await self.request_driver_setup_user_input( - websocket, action.title, action.settings - ) + await self.request_driver_setup_user_input(websocket, action.title, action.settings) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.request_driver_setup_user_confirmation( @@ -1346,9 +1274,7 @@ async def driver_setup_progress(self, websocket) -> None: """ data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) # pylint: disable=R0917 async def request_driver_setup_user_confirmation( @@ -1384,9 +1310,7 @@ async def request_driver_setup_user_confirmation( }, } - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def request_driver_setup_user_input( self, websocket, title: str | dict[str, str], settings: dict[str, Any] | list @@ -1395,30 +1319,22 @@ async def request_driver_setup_user_input( data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", - "require_user_action": { - "input": {"title": _to_language_object(title), "settings": settings} - }, + "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, } - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def driver_setup_complete(self, websocket) -> None: """Send a driver setup complete event to Remote Two/3.""" data = {"event_type": "STOP", "state": "OK"} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def driver_setup_error(self, websocket, error="OTHER") -> None: """Send a driver setup error event to Remote Two/3.""" data = {"event_type": "STOP", "state": "ERROR", "error": error} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) @staticmethod def _wrap_event_listener(listener: Callable) -> Callable: @@ -1439,9 +1355,7 @@ def _wrap_event_listener(listener: Callable) -> Callable: params = list(sig.parameters.values()) - accepts_varargs = any( - p.kind == inspect.Parameter.VAR_POSITIONAL for p in params - ) + accepts_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) accepts_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params) # How many positional args can the listener accept (excluding *args/**kwargs)? @@ -1455,18 +1369,13 @@ def _wrap_event_listener(listener: Callable) -> Callable: accepted_kw = { p.name for p in params - if p.kind - in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) } @wraps(listener) def wrapper(*args: Any, **kwargs: Any): call_args = args if accepts_varargs else args[:max_positional] - call_kwargs = ( - kwargs - if accepts_varkw - else {k: v for k, v in kwargs.items() if k in accepted_kw} - ) + call_kwargs = kwargs if accepts_varkw else {k: v for k, v in kwargs.items() if k in accepted_kw} return listener(*call_args, **call_kwargs) return wrapper @@ -1513,9 +1422,7 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: """ self._events.remove_all_listeners(event) - async def get_supported_entity_types( - self, websocket, *, timeout: float = 5.0 - ) -> list[str]: + async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> list[str]: """Request supported entity types from client and return msg_data.""" resp = await self._ws_request( websocket, @@ -1530,9 +1437,7 @@ async def get_supported_entity_types( ) return resp.get("msg_data", []) - async def get_version( - self, websocket, *, timeout: float = 5.0 - ) -> dict[str, Any] | None: + async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1548,9 +1453,7 @@ async def get_version( return resp.get("msg_data") - async def get_localization_cfg( - self, websocket, *, timeout: float = 5.0 - ) -> dict[str, Any] | None: + async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: """Request localization config and return msg_data.""" resp = await self._ws_request( websocket, @@ -1567,6 +1470,39 @@ async def get_localization_cfg( return resp.get("msg_data") + async def _update_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> None: + """Update supported entity types by remote.""" + await asyncio.sleep(0) + try: + self._supported_entity_types = await self.get_supported_entity_types(websocket, timeout=timeout) + _LOG.debug( + "[%s] Supported entity types %s", + websocket.remote_address, + self._supported_entity_types, + ) + except Exception as ex: # pylint: disable=W0718 + _LOG.error( + "[%s] Unable to retrieve entity types %s", + websocket.remote_address, + ex, + ) + + async def _get_available_entities(self, websocket, req_id) -> None: + if self._supported_entity_types is None: + # Request supported entity types from remote + await self._update_supported_entity_types(websocket) + available_entities = self._available_entities.get_all() + if self._supported_entity_types: + available_entities = [ + entity for entity in available_entities if entity.get("entity_type") in self._supported_entity_types + ] + await self._send_ws_response( + websocket, + req_id, + uc.WsMsgEvents.AVAILABLE_ENTITIES, + {"available_entities": available_entities}, + ) + ############## # Properties # ############## @@ -1615,9 +1551,7 @@ def _to_language_object(text: str | dict[str, str] | None) -> dict[str, str] | N return text -def _get_default_language_string( - text: str | dict[str, str] | None, default_text="Undefined" -) -> str: +def _get_default_language_string(text: str | dict[str, str] | None, default_text="Undefined") -> str: if text is None: return default_text @@ -1677,10 +1611,7 @@ def local_hostname() -> str: # local hostname keeps on changing with a increasing number suffix! # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end - return ( - os.getenv("UC_MDNS_LOCAL_HOSTNAME") - or f'{socket.gethostname().split(".", 1)[0]}.local.' - ) + return os.getenv("UC_MDNS_LOCAL_HOSTNAME") or f'{socket.gethostname().split(".", 1)[0]}.local.' def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: @@ -1705,11 +1636,7 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in log_upd["msg_data"] and MediaAttr.MEDIA_IMAGE_URL in log_upd["msg_data"]["attributes"] - and ( - media_image_url := log_upd["msg_data"]["attributes"][ - MediaAttr.MEDIA_IMAGE_URL - ] - ) + and (media_image_url := log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL]) and media_image_url.startswith("data:") ): log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" @@ -1718,9 +1645,7 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in item and MediaAttr.MEDIA_IMAGE_URL in item["attributes"] - and ( - media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL] - ) + and (media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL]) and media_image_url.startswith("data:") ): item["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" From d62dbe74b6a8d3a03c462f665f0049e6d02d7b4e Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:01:46 +0200 Subject: [PATCH 38/43] Linting --- ucapi/api.py | 291 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 219 insertions(+), 72 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 6cf76f0..2c037df 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -143,7 +143,9 @@ def _resolve_config_dir() -> str: def _voice_key(websocket: Any, session_id: int) -> VoiceSessionKey: return websocket, int(session_id) - async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = None): + async def init( + self, driver_path: str, setup_handler: uc.SetupHandler | None = None + ): """ Load driver configuration and start integration-API WebSocket server. @@ -154,7 +156,9 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N self._driver_path = driver_path self._setup_handler = setup_handler - self._configured_entities.add_listener(uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated) + self._configured_entities.add_listener( + uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated + ) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: @@ -169,13 +173,17 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N _adjust_driver_url(self._driver_info, port) - disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") + disable_mdns_publish = os.getenv( + "UC_DISABLE_MDNS_PUBLISH", "false" + ).lower() in ("true", "1") if disable_mdns_publish is False: # Setup zeroconf service info name = f'{self._driver_info["driver_id"]}._uc-integration._tcp.local.' hostname = local_hostname() - driver_name = _get_default_language_string(self._driver_info["name"], "Unknown driver") + driver_name = _get_default_language_string( + self._driver_info["name"], "Unknown driver" + ) _LOG.debug("Publishing driver: name=%s, host=%s:%d", name, hostname, port) @@ -195,7 +203,9 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N await zeroconf.async_register_service(info) host = interface if interface is not None else "0.0.0.0" - self._server_task = self._loop.create_task(self._start_web_socket_server(host, port)) + self._server_task = self._loop.create_task( + self._start_web_socket_server(host, port) + ) _LOG.info( "Driver is up: %s, version: %s, api: %s, listening on: %s:%d", @@ -213,7 +223,9 @@ async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes "attributes": attributes, } - await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) + await self._broadcast_ws_event( + uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY + ) async def _start_web_socket_server(self, host: str, port: int) -> None: async with serve(self._handle_ws, host, port): @@ -236,14 +248,22 @@ async def _handle_ws(self, websocket) -> None: try: _LOG.info("WS: Client added: %s", websocket.remote_address) - ctx.consumer_task = self._loop.create_task(self._ws_consumer(websocket, ctx)) - ctx.producer_task = self._loop.create_task(self._ws_producer(websocket, ctx)) + ctx.consumer_task = self._loop.create_task( + self._ws_consumer(websocket, ctx) + ) + ctx.producer_task = self._loop.create_task( + self._ws_producer(websocket, ctx) + ) ctx.router_task = self._loop.create_task(self._ws_router(websocket, ctx)) # authenticate on connection await self._authenticate(websocket, True) self._events.emit(uc.Events.CLIENT_CONNECTED, websocket=websocket) - tasks = [t for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] if t is not None] + tasks = [ + t + for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] + if t is not None + ] done, pending = await asyncio.wait( tasks, return_when=asyncio.FIRST_COMPLETED, @@ -254,7 +274,9 @@ async def _handle_ws(self, websocket) -> None: results = await asyncio.gather(*done, *pending, return_exceptions=True) for result in results: - if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): + if isinstance(result, Exception) and not isinstance( + result, asyncio.CancelledError + ): raise result except ConnectionClosedOK: @@ -355,11 +377,15 @@ async def _enqueue_ws_payload(self, websocket, payload: dict[str, Any]) -> None: return if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload)) + _LOG.debug( + "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload) + ) await ctx.outgoing.put(json.dumps(payload)) - async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None) -> None: + async def _send_ok_result( + self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None + ) -> None: """ Send a WebSocket success message with status code OK. @@ -370,7 +396,9 @@ async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] Raises: websockets.ConnectionClosed: When the connection is closed. """ - await self._send_ws_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) + await self._send_ws_response( + websocket, req_id, "result", msg_data, uc.StatusCodes.OK + ) async def _send_error_result( self, @@ -392,6 +420,7 @@ async def _send_error_result( """ await self._send_ws_response(websocket, req_id, "result", msg_data, status_code) + # pylint: disable=too-many-positional-arguments async def _send_ws_response( self, websocket, @@ -421,7 +450,9 @@ async def _send_ws_response( } await self._enqueue_ws_payload(websocket, data) - async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: + async def _broadcast_ws_event( + self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory + ) -> None: """ Send the given event-message to all connected WebSocket clients. @@ -436,10 +467,14 @@ async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category for websocket in self._clients.copy(): try: await self._enqueue_ws_payload(websocket, data) - except Exception: - _LOG.exception("Failed to enqueue broadcast for %s", websocket.remote_address) + except Exception: # pylint: disable=broad-exception-caught + _LOG.exception( + "Failed to enqueue broadcast for %s", websocket.remote_address + ) - async def _send_ws_event(self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: + async def _send_ws_event( + self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory + ) -> None: """ Send an event-message to the given WebSocket client. @@ -572,7 +607,9 @@ async def _process_ws_binary_message(self, websocket, data: bytes) -> None: - Logs errors on deserialization failures and unknown message kinds. """ if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("[%s] <-: ", websocket.remote_address, len(data)) + _LOG.debug( + "[%s] <-: ", websocket.remote_address, len(data) + ) # Parse IntegrationMessage from bytes try: @@ -609,7 +646,7 @@ async def _cleanup_ws(self, websocket) -> None: for key in keys_to_cleanup: try: await self._cleanup_voice_session(key, VoiceEndReason.REMOTE) - except Exception as ex: + except Exception as ex: # pylint: disable=broad-exception-caught _LOG.exception( "[%s] WS: Error during voice session cleanup for session_id=%s: %s", websocket.remote_address, @@ -834,7 +871,9 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: # If handler not started yet, start it now (best effort) if ctx.handler_task is None and self._voice_handler is not None: try: - ctx.handler_task = self._loop.create_task(self._run_voice_handler(ctx.session)) + ctx.handler_task = self._loop.create_task( + self._run_voice_handler(ctx.session) + ) except Exception: # pylint: disable=W0718 _LOG.exception( "Failed to start voice handler on timeout for session %s", @@ -846,7 +885,9 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: await self._cleanup_voice_session(key) # pylint: disable=R0912 - async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _handle_ws_request_msg( + self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if msg == uc.WsMessages.GET_DRIVER_VERSION: await self._send_ws_response( websocket, @@ -884,7 +925,9 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat await self._unsubscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) elif msg == uc.WsMessages.GET_DRIVER_METADATA: - await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info) + await self._send_ws_response( + websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info + ) elif msg == uc.WsMessages.SETUP_DRIVER: if not await self._setup_driver(websocket, req_id, msg_data): # sleep for web-configurator quirks... @@ -895,7 +938,9 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat await asyncio.sleep(0.5) await self.driver_setup_error(websocket) - async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[str, Any] | None) -> None: + async def _handle_ws_event_msg( + self, websocket: Any, msg: str, msg_data: dict[str, Any] | None + ) -> None: if msg == uc.WsMsgEvents.CONNECT: self._events.emit(uc.Events.CONNECT, websocket=websocket) elif msg == uc.WsMsgEvents.DISCONNECT: @@ -906,7 +951,9 @@ async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[st self._events.emit(uc.Events.EXIT_STANDBY, websocket=websocket) elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP: if not self._setup_handler: - _LOG.warning("Received abort_driver_setup event, but no setup handler provided by the driver!") # noqa + _LOG.warning( + "Received abort_driver_setup event, but no setup handler provided by the driver!" + ) # noqa return if "error" in msg_data: @@ -916,7 +963,9 @@ async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[st error = uc.IntegrationSetupError.OTHER await self._setup_handler(uc.AbortDriverSetup(error)) else: - _LOG.warning("Unsupported abort_driver_setup payload received: %s", msg_data) + _LOG.warning( + "Unsupported abort_driver_setup payload received: %s", msg_data + ) async def _authenticate(self, websocket, success: bool) -> None: await self._send_ws_response( @@ -952,7 +1001,9 @@ async def set_device_state(self, state: uc.DeviceStates) -> None: uc.EventCategory.DEVICE, ) - async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> None: + async def _subscribe_events( + self, websocket: Any, msg_data: dict[str, Any] | None + ) -> None: if msg_data is None: _LOG.warning("Ignoring _subscribe_events: called with empty msg_data") return @@ -972,7 +1023,9 @@ async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | Non websocket=websocket, ) - async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> bool: + async def _unsubscribe_events( + self, websocket: Any, msg_data: dict[str, Any] | None + ) -> bool: if msg_data is None: _LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") return False @@ -991,17 +1044,23 @@ async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | N return res - async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _entity_command( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring entity command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None cmd_id = msg_data["cmd_id"] if "cmd_id" in msg_data else None if entity_id is None or cmd_id is None: _LOG.warning("Ignoring command: missing entity_id or cmd_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1064,20 +1123,28 @@ async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] "Old Entity.command signature detected for %s, trying old signature. Please update the command signature.", entity.id, ) - result = await entity.command(cmd_id, msg_data["params"] if "params" in msg_data else None) + result = await entity.command( + cmd_id, msg_data["params"] if "params" in msg_data else None + ) await self.acknowledge_command(websocket, req_id, result) - async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _browse_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring browse_media command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring browse_media command: missing entity_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1093,8 +1160,12 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | try: data = BrowseMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error("Cannot browse media for '%s': wrong format %s", entity_id, msg_data) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + _LOG.error( + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return # call integration driver to handle browse request @@ -1102,7 +1173,9 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | result = await entity.browse(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.browse for '%s'", entity_id) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.SERVER_ERROR + ) return if isinstance(result, BrowseResults): @@ -1116,16 +1189,22 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | else: await self.acknowledge_command(websocket, req_id, result) - async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _search_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring search_media command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring search_media command: missing entity_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1140,15 +1219,21 @@ async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | try: data = SearchMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error("Cannot search media for '%s': wrong format %s", entity_id, msg_data) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + _LOG.error( + "Cannot search media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return try: result = await entity.search(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.search for '%s'", entity_id) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.SERVER_ERROR + ) return if isinstance(result, SearchResults): @@ -1162,7 +1247,9 @@ async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | else: await self.acknowledge_command(websocket, req_id, result) - async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + async def _setup_driver( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> bool: await self.acknowledge_command(websocket, req_id) if msg_data is None or "setup_data" not in msg_data: @@ -1172,18 +1259,24 @@ async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | # 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!") # noqa + _LOG.error( + "Received setup_driver request, but no setup handler provided by the driver!" + ) # noqa return False result = False try: action = await self._setup_handler( - uc.DriverSetupRequest(msg_data.get("reconfigure") or False, msg_data["setup_data"]) + uc.DriverSetupRequest( + msg_data.get("reconfigure") or False, msg_data["setup_data"] + ) ) if isinstance(action, uc.RequestUserInput): await self.driver_setup_progress(websocket) - await self.request_driver_setup_user_input(websocket, action.title, action.settings) + await self.request_driver_setup_user_input( + websocket, action.title, action.settings + ) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.driver_setup_progress(websocket) @@ -1204,11 +1297,15 @@ async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | return result - async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + async def _set_driver_user_data( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> bool: await self.acknowledge_command(websocket, req_id) if not self._setup_handler: - _LOG.error("Received set_driver_user_data request, but no setup handler provided by the driver!") # noqa + _LOG.error( + "Received set_driver_user_data request, but no setup handler provided by the driver!" + ) # noqa return False if "input_values" in msg_data or "confirm" in msg_data: @@ -1216,19 +1313,27 @@ async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str await asyncio.sleep(0.5) await self.driver_setup_progress(websocket) else: - _LOG.warning("Unsupported set_driver_user_data payload received: %s", msg_data) + _LOG.warning( + "Unsupported set_driver_user_data payload received: %s", msg_data + ) return False result = False try: action = uc.SetupError() if "input_values" in msg_data: - action = await self._setup_handler(uc.UserDataResponse(msg_data["input_values"])) + action = await self._setup_handler( + uc.UserDataResponse(msg_data["input_values"]) + ) elif "confirm" in msg_data: - action = await self._setup_handler(uc.UserConfirmationResponse(msg_data["confirm"])) + action = await self._setup_handler( + uc.UserConfirmationResponse(msg_data["confirm"]) + ) if isinstance(action, uc.RequestUserInput): - await self.request_driver_setup_user_input(websocket, action.title, action.settings) + await self.request_driver_setup_user_input( + websocket, action.title, action.settings + ) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.request_driver_setup_user_confirmation( @@ -1274,7 +1379,9 @@ async def driver_setup_progress(self, websocket) -> None: """ data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) # pylint: disable=R0917 async def request_driver_setup_user_confirmation( @@ -1310,7 +1417,9 @@ async def request_driver_setup_user_confirmation( }, } - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def request_driver_setup_user_input( self, websocket, title: str | dict[str, str], settings: dict[str, Any] | list @@ -1319,22 +1428,30 @@ async def request_driver_setup_user_input( data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", - "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, + "require_user_action": { + "input": {"title": _to_language_object(title), "settings": settings} + }, } - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def driver_setup_complete(self, websocket) -> None: """Send a driver setup complete event to Remote Two/3.""" data = {"event_type": "STOP", "state": "OK"} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def driver_setup_error(self, websocket, error="OTHER") -> None: """Send a driver setup error event to Remote Two/3.""" data = {"event_type": "STOP", "state": "ERROR", "error": error} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) @staticmethod def _wrap_event_listener(listener: Callable) -> Callable: @@ -1355,7 +1472,9 @@ def _wrap_event_listener(listener: Callable) -> Callable: params = list(sig.parameters.values()) - accepts_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) + accepts_varargs = any( + p.kind == inspect.Parameter.VAR_POSITIONAL for p in params + ) accepts_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params) # How many positional args can the listener accept (excluding *args/**kwargs)? @@ -1369,13 +1488,18 @@ def _wrap_event_listener(listener: Callable) -> Callable: accepted_kw = { p.name for p in params - if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + if p.kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) } @wraps(listener) def wrapper(*args: Any, **kwargs: Any): call_args = args if accepts_varargs else args[:max_positional] - call_kwargs = kwargs if accepts_varkw else {k: v for k, v in kwargs.items() if k in accepted_kw} + call_kwargs = ( + kwargs + if accepts_varkw + else {k: v for k, v in kwargs.items() if k in accepted_kw} + ) return listener(*call_args, **call_kwargs) return wrapper @@ -1422,7 +1546,9 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: """ self._events.remove_all_listeners(event) - async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> list[str]: + async def get_supported_entity_types( + self, websocket, *, timeout: float = 5.0 + ) -> list[str]: """Request supported entity types from client and return msg_data.""" resp = await self._ws_request( websocket, @@ -1437,7 +1563,9 @@ async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) - ) return resp.get("msg_data", []) - async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: + async def get_version( + self, websocket, *, timeout: float = 5.0 + ) -> dict[str, Any] | None: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1453,7 +1581,9 @@ async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any return resp.get("msg_data") - async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: + async def get_localization_cfg( + self, websocket, *, timeout: float = 5.0 + ) -> dict[str, Any] | None: """Request localization config and return msg_data.""" resp = await self._ws_request( websocket, @@ -1470,11 +1600,15 @@ async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict return resp.get("msg_data") - async def _update_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> None: + async def _update_supported_entity_types( + self, websocket, *, timeout: float = 5.0 + ) -> None: """Update supported entity types by remote.""" await asyncio.sleep(0) try: - self._supported_entity_types = await self.get_supported_entity_types(websocket, timeout=timeout) + self._supported_entity_types = await self.get_supported_entity_types( + websocket, timeout=timeout + ) _LOG.debug( "[%s] Supported entity types %s", websocket.remote_address, @@ -1494,7 +1628,9 @@ async def _get_available_entities(self, websocket, req_id) -> None: available_entities = self._available_entities.get_all() if self._supported_entity_types: available_entities = [ - entity for entity in available_entities if entity.get("entity_type") in self._supported_entity_types + entity + for entity in available_entities + if entity.get("entity_type") in self._supported_entity_types ] await self._send_ws_response( websocket, @@ -1551,7 +1687,9 @@ def _to_language_object(text: str | dict[str, str] | None) -> dict[str, str] | N return text -def _get_default_language_string(text: str | dict[str, str] | None, default_text="Undefined") -> str: +def _get_default_language_string( + text: str | dict[str, str] | None, default_text="Undefined" +) -> str: if text is None: return default_text @@ -1611,7 +1749,10 @@ def local_hostname() -> str: # local hostname keeps on changing with a increasing number suffix! # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end - return os.getenv("UC_MDNS_LOCAL_HOSTNAME") or f'{socket.gethostname().split(".", 1)[0]}.local.' + return ( + os.getenv("UC_MDNS_LOCAL_HOSTNAME") + or f'{socket.gethostname().split(".", 1)[0]}.local.' + ) def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: @@ -1636,7 +1777,11 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in log_upd["msg_data"] and MediaAttr.MEDIA_IMAGE_URL in log_upd["msg_data"]["attributes"] - and (media_image_url := log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL]) + and ( + media_image_url := log_upd["msg_data"]["attributes"][ + MediaAttr.MEDIA_IMAGE_URL + ] + ) and media_image_url.startswith("data:") ): log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" @@ -1645,7 +1790,9 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in item and MediaAttr.MEDIA_IMAGE_URL in item["attributes"] - and (media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL]) + and ( + media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL] + ) and media_image_url.startswith("data:") ): item["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" From 4914534fe3a9584af9e6f6836db57fc3a521cb53 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:06:44 +0200 Subject: [PATCH 39/43] Linting --- ucapi/api.py | 287 +++++++++++++-------------------------------------- 1 file changed, 70 insertions(+), 217 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 2c037df..11d6c40 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -143,9 +143,7 @@ def _resolve_config_dir() -> str: def _voice_key(websocket: Any, session_id: int) -> VoiceSessionKey: return websocket, int(session_id) - async def init( - self, driver_path: str, setup_handler: uc.SetupHandler | None = None - ): + async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = None): """ Load driver configuration and start integration-API WebSocket server. @@ -156,9 +154,7 @@ async def init( self._driver_path = driver_path self._setup_handler = setup_handler - self._configured_entities.add_listener( - uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated - ) + self._configured_entities.add_listener(uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: @@ -173,17 +169,13 @@ async def init( _adjust_driver_url(self._driver_info, port) - disable_mdns_publish = os.getenv( - "UC_DISABLE_MDNS_PUBLISH", "false" - ).lower() in ("true", "1") + disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") if disable_mdns_publish is False: # Setup zeroconf service info name = f'{self._driver_info["driver_id"]}._uc-integration._tcp.local.' hostname = local_hostname() - driver_name = _get_default_language_string( - self._driver_info["name"], "Unknown driver" - ) + driver_name = _get_default_language_string(self._driver_info["name"], "Unknown driver") _LOG.debug("Publishing driver: name=%s, host=%s:%d", name, hostname, port) @@ -203,9 +195,7 @@ async def init( await zeroconf.async_register_service(info) host = interface if interface is not None else "0.0.0.0" - self._server_task = self._loop.create_task( - self._start_web_socket_server(host, port) - ) + self._server_task = self._loop.create_task(self._start_web_socket_server(host, port)) _LOG.info( "Driver is up: %s, version: %s, api: %s, listening on: %s:%d", @@ -223,9 +213,7 @@ async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes "attributes": attributes, } - await self._broadcast_ws_event( - uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY - ) + await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) async def _start_web_socket_server(self, host: str, port: int) -> None: async with serve(self._handle_ws, host, port): @@ -248,22 +236,14 @@ async def _handle_ws(self, websocket) -> None: try: _LOG.info("WS: Client added: %s", websocket.remote_address) - ctx.consumer_task = self._loop.create_task( - self._ws_consumer(websocket, ctx) - ) - ctx.producer_task = self._loop.create_task( - self._ws_producer(websocket, ctx) - ) + ctx.consumer_task = self._loop.create_task(self._ws_consumer(websocket, ctx)) + ctx.producer_task = self._loop.create_task(self._ws_producer(websocket, ctx)) ctx.router_task = self._loop.create_task(self._ws_router(websocket, ctx)) # authenticate on connection await self._authenticate(websocket, True) self._events.emit(uc.Events.CLIENT_CONNECTED, websocket=websocket) - tasks = [ - t - for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] - if t is not None - ] + tasks = [t for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] if t is not None] done, pending = await asyncio.wait( tasks, return_when=asyncio.FIRST_COMPLETED, @@ -274,9 +254,7 @@ async def _handle_ws(self, websocket) -> None: results = await asyncio.gather(*done, *pending, return_exceptions=True) for result in results: - if isinstance(result, Exception) and not isinstance( - result, asyncio.CancelledError - ): + if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): raise result except ConnectionClosedOK: @@ -377,15 +355,11 @@ async def _enqueue_ws_payload(self, websocket, payload: dict[str, Any]) -> None: return if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload) - ) + _LOG.debug("[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload)) await ctx.outgoing.put(json.dumps(payload)) - async def _send_ok_result( - self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None - ) -> None: + async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None) -> None: """ Send a WebSocket success message with status code OK. @@ -396,9 +370,7 @@ async def _send_ok_result( Raises: websockets.ConnectionClosed: When the connection is closed. """ - await self._send_ws_response( - websocket, req_id, "result", msg_data, uc.StatusCodes.OK - ) + await self._send_ws_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) async def _send_error_result( self, @@ -450,9 +422,7 @@ async def _send_ws_response( } await self._enqueue_ws_payload(websocket, data) - async def _broadcast_ws_event( - self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory - ) -> None: + async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: """ Send the given event-message to all connected WebSocket clients. @@ -468,13 +438,9 @@ async def _broadcast_ws_event( try: await self._enqueue_ws_payload(websocket, data) except Exception: # pylint: disable=broad-exception-caught - _LOG.exception( - "Failed to enqueue broadcast for %s", websocket.remote_address - ) + _LOG.exception("Failed to enqueue broadcast for %s", websocket.remote_address) - async def _send_ws_event( - self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory - ) -> None: + async def _send_ws_event(self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: """ Send an event-message to the given WebSocket client. @@ -516,7 +482,6 @@ async def _process_ws_message(self, websocket, data: dict[str, Any]) -> None: def _handle_pending_response(self, websocket, data: dict[str, Any]) -> None: """Resolve the response message that corresponds to a pending request from the driver.""" - resp_id = data.get("req_id", data.get("id")) if resp_id is None: _LOG.warning( @@ -607,9 +572,7 @@ async def _process_ws_binary_message(self, websocket, data: bytes) -> None: - Logs errors on deserialization failures and unknown message kinds. """ if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] <-: ", websocket.remote_address, len(data) - ) + _LOG.debug("[%s] <-: ", websocket.remote_address, len(data)) # Parse IntegrationMessage from bytes try: @@ -871,9 +834,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: # If handler not started yet, start it now (best effort) if ctx.handler_task is None and self._voice_handler is not None: try: - ctx.handler_task = self._loop.create_task( - self._run_voice_handler(ctx.session) - ) + ctx.handler_task = self._loop.create_task(self._run_voice_handler(ctx.session)) except Exception: # pylint: disable=W0718 _LOG.exception( "Failed to start voice handler on timeout for session %s", @@ -885,9 +846,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: await self._cleanup_voice_session(key) # pylint: disable=R0912 - async def _handle_ws_request_msg( - self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None) -> None: if msg == uc.WsMessages.GET_DRIVER_VERSION: await self._send_ws_response( websocket, @@ -925,9 +884,7 @@ async def _handle_ws_request_msg( await self._unsubscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) elif msg == uc.WsMessages.GET_DRIVER_METADATA: - await self._send_ws_response( - websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info - ) + await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info) elif msg == uc.WsMessages.SETUP_DRIVER: if not await self._setup_driver(websocket, req_id, msg_data): # sleep for web-configurator quirks... @@ -938,9 +895,7 @@ async def _handle_ws_request_msg( await asyncio.sleep(0.5) await self.driver_setup_error(websocket) - async def _handle_ws_event_msg( - self, websocket: Any, msg: str, msg_data: dict[str, Any] | None - ) -> None: + async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[str, Any] | None) -> None: if msg == uc.WsMsgEvents.CONNECT: self._events.emit(uc.Events.CONNECT, websocket=websocket) elif msg == uc.WsMsgEvents.DISCONNECT: @@ -951,9 +906,7 @@ async def _handle_ws_event_msg( self._events.emit(uc.Events.EXIT_STANDBY, websocket=websocket) elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP: if not self._setup_handler: - _LOG.warning( - "Received abort_driver_setup event, but no setup handler provided by the driver!" - ) # noqa + _LOG.warning("Received abort_driver_setup event, but no setup handler provided by the driver!") # noqa return if "error" in msg_data: @@ -963,9 +916,7 @@ async def _handle_ws_event_msg( error = uc.IntegrationSetupError.OTHER await self._setup_handler(uc.AbortDriverSetup(error)) else: - _LOG.warning( - "Unsupported abort_driver_setup payload received: %s", msg_data - ) + _LOG.warning("Unsupported abort_driver_setup payload received: %s", msg_data) async def _authenticate(self, websocket, success: bool) -> None: await self._send_ws_response( @@ -1001,9 +952,7 @@ async def set_device_state(self, state: uc.DeviceStates) -> None: uc.EventCategory.DEVICE, ) - async def _subscribe_events( - self, websocket: Any, msg_data: dict[str, Any] | None - ) -> None: + async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> None: if msg_data is None: _LOG.warning("Ignoring _subscribe_events: called with empty msg_data") return @@ -1023,9 +972,7 @@ async def _subscribe_events( websocket=websocket, ) - async def _unsubscribe_events( - self, websocket: Any, msg_data: dict[str, Any] | None - ) -> bool: + async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> bool: if msg_data is None: _LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") return False @@ -1044,23 +991,17 @@ async def _unsubscribe_events( return res - async def _entity_command( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring entity command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None cmd_id = msg_data["cmd_id"] if "cmd_id" in msg_data else None if entity_id is None or cmd_id is None: _LOG.warning("Ignoring command: missing entity_id or cmd_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1123,28 +1064,20 @@ async def _entity_command( "Old Entity.command signature detected for %s, trying old signature. Please update the command signature.", entity.id, ) - result = await entity.command( - cmd_id, msg_data["params"] if "params" in msg_data else None - ) + result = await entity.command(cmd_id, msg_data["params"] if "params" in msg_data else None) await self.acknowledge_command(websocket, req_id, result) - async def _browse_media( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring browse_media command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring browse_media command: missing entity_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1160,12 +1093,8 @@ async def _browse_media( try: data = BrowseMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error( - "Cannot browse media for '%s': wrong format %s", entity_id, msg_data - ) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + _LOG.error("Cannot browse media for '%s': wrong format %s", entity_id, msg_data) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return # call integration driver to handle browse request @@ -1173,9 +1102,7 @@ async def _browse_media( result = await entity.browse(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.browse for '%s'", entity_id) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.SERVER_ERROR - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) return if isinstance(result, BrowseResults): @@ -1189,22 +1116,16 @@ async def _browse_media( else: await self.acknowledge_command(websocket, req_id, result) - async def _search_media( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring search_media command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring search_media command: missing entity_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1219,21 +1140,15 @@ async def _search_media( try: data = SearchMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error( - "Cannot search media for '%s': wrong format %s", entity_id, msg_data - ) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + _LOG.error("Cannot search media for '%s': wrong format %s", entity_id, msg_data) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return try: result = await entity.search(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.search for '%s'", entity_id) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.SERVER_ERROR - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) return if isinstance(result, SearchResults): @@ -1247,9 +1162,7 @@ async def _search_media( else: await self.acknowledge_command(websocket, req_id, result) - async def _setup_driver( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> bool: + async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: await self.acknowledge_command(websocket, req_id) if msg_data is None or "setup_data" not in msg_data: @@ -1259,24 +1172,18 @@ async def _setup_driver( # 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!" - ) # noqa + _LOG.error("Received setup_driver request, but no setup handler provided by the driver!") # noqa return False result = False try: action = await self._setup_handler( - uc.DriverSetupRequest( - msg_data.get("reconfigure") or False, msg_data["setup_data"] - ) + uc.DriverSetupRequest(msg_data.get("reconfigure") or False, msg_data["setup_data"]) ) if isinstance(action, uc.RequestUserInput): await self.driver_setup_progress(websocket) - await self.request_driver_setup_user_input( - websocket, action.title, action.settings - ) + await self.request_driver_setup_user_input(websocket, action.title, action.settings) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.driver_setup_progress(websocket) @@ -1297,15 +1204,11 @@ async def _setup_driver( return result - async def _set_driver_user_data( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> bool: + async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: await self.acknowledge_command(websocket, req_id) if not self._setup_handler: - _LOG.error( - "Received set_driver_user_data request, but no setup handler provided by the driver!" - ) # noqa + _LOG.error("Received set_driver_user_data request, but no setup handler provided by the driver!") # noqa return False if "input_values" in msg_data or "confirm" in msg_data: @@ -1313,27 +1216,19 @@ async def _set_driver_user_data( await asyncio.sleep(0.5) await self.driver_setup_progress(websocket) else: - _LOG.warning( - "Unsupported set_driver_user_data payload received: %s", msg_data - ) + _LOG.warning("Unsupported set_driver_user_data payload received: %s", msg_data) return False result = False try: action = uc.SetupError() if "input_values" in msg_data: - action = await self._setup_handler( - uc.UserDataResponse(msg_data["input_values"]) - ) + action = await self._setup_handler(uc.UserDataResponse(msg_data["input_values"])) elif "confirm" in msg_data: - action = await self._setup_handler( - uc.UserConfirmationResponse(msg_data["confirm"]) - ) + action = await self._setup_handler(uc.UserConfirmationResponse(msg_data["confirm"])) if isinstance(action, uc.RequestUserInput): - await self.request_driver_setup_user_input( - websocket, action.title, action.settings - ) + await self.request_driver_setup_user_input(websocket, action.title, action.settings) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.request_driver_setup_user_confirmation( @@ -1379,9 +1274,7 @@ async def driver_setup_progress(self, websocket) -> None: """ data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) # pylint: disable=R0917 async def request_driver_setup_user_confirmation( @@ -1417,9 +1310,7 @@ async def request_driver_setup_user_confirmation( }, } - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def request_driver_setup_user_input( self, websocket, title: str | dict[str, str], settings: dict[str, Any] | list @@ -1428,30 +1319,22 @@ async def request_driver_setup_user_input( data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", - "require_user_action": { - "input": {"title": _to_language_object(title), "settings": settings} - }, + "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, } - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def driver_setup_complete(self, websocket) -> None: """Send a driver setup complete event to Remote Two/3.""" data = {"event_type": "STOP", "state": "OK"} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def driver_setup_error(self, websocket, error="OTHER") -> None: """Send a driver setup error event to Remote Two/3.""" data = {"event_type": "STOP", "state": "ERROR", "error": error} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) @staticmethod def _wrap_event_listener(listener: Callable) -> Callable: @@ -1472,9 +1355,7 @@ def _wrap_event_listener(listener: Callable) -> Callable: params = list(sig.parameters.values()) - accepts_varargs = any( - p.kind == inspect.Parameter.VAR_POSITIONAL for p in params - ) + accepts_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) accepts_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params) # How many positional args can the listener accept (excluding *args/**kwargs)? @@ -1488,18 +1369,13 @@ def _wrap_event_listener(listener: Callable) -> Callable: accepted_kw = { p.name for p in params - if p.kind - in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) } @wraps(listener) def wrapper(*args: Any, **kwargs: Any): call_args = args if accepts_varargs else args[:max_positional] - call_kwargs = ( - kwargs - if accepts_varkw - else {k: v for k, v in kwargs.items() if k in accepted_kw} - ) + call_kwargs = kwargs if accepts_varkw else {k: v for k, v in kwargs.items() if k in accepted_kw} return listener(*call_args, **call_kwargs) return wrapper @@ -1546,9 +1422,7 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: """ self._events.remove_all_listeners(event) - async def get_supported_entity_types( - self, websocket, *, timeout: float = 5.0 - ) -> list[str]: + async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> list[str]: """Request supported entity types from client and return msg_data.""" resp = await self._ws_request( websocket, @@ -1563,9 +1437,7 @@ async def get_supported_entity_types( ) return resp.get("msg_data", []) - async def get_version( - self, websocket, *, timeout: float = 5.0 - ) -> dict[str, Any] | None: + async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1581,9 +1453,7 @@ async def get_version( return resp.get("msg_data") - async def get_localization_cfg( - self, websocket, *, timeout: float = 5.0 - ) -> dict[str, Any] | None: + async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: """Request localization config and return msg_data.""" resp = await self._ws_request( websocket, @@ -1600,15 +1470,11 @@ async def get_localization_cfg( return resp.get("msg_data") - async def _update_supported_entity_types( - self, websocket, *, timeout: float = 5.0 - ) -> None: + async def _update_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> None: """Update supported entity types by remote.""" await asyncio.sleep(0) try: - self._supported_entity_types = await self.get_supported_entity_types( - websocket, timeout=timeout - ) + self._supported_entity_types = await self.get_supported_entity_types(websocket, timeout=timeout) _LOG.debug( "[%s] Supported entity types %s", websocket.remote_address, @@ -1628,9 +1494,7 @@ async def _get_available_entities(self, websocket, req_id) -> None: available_entities = self._available_entities.get_all() if self._supported_entity_types: available_entities = [ - entity - for entity in available_entities - if entity.get("entity_type") in self._supported_entity_types + entity for entity in available_entities if entity.get("entity_type") in self._supported_entity_types ] await self._send_ws_response( websocket, @@ -1687,9 +1551,7 @@ def _to_language_object(text: str | dict[str, str] | None) -> dict[str, str] | N return text -def _get_default_language_string( - text: str | dict[str, str] | None, default_text="Undefined" -) -> str: +def _get_default_language_string(text: str | dict[str, str] | None, default_text="Undefined") -> str: if text is None: return default_text @@ -1749,10 +1611,7 @@ def local_hostname() -> str: # local hostname keeps on changing with a increasing number suffix! # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end - return ( - os.getenv("UC_MDNS_LOCAL_HOSTNAME") - or f'{socket.gethostname().split(".", 1)[0]}.local.' - ) + return os.getenv("UC_MDNS_LOCAL_HOSTNAME") or f'{socket.gethostname().split(".", 1)[0]}.local.' def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: @@ -1777,11 +1636,7 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in log_upd["msg_data"] and MediaAttr.MEDIA_IMAGE_URL in log_upd["msg_data"]["attributes"] - and ( - media_image_url := log_upd["msg_data"]["attributes"][ - MediaAttr.MEDIA_IMAGE_URL - ] - ) + and (media_image_url := log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL]) and media_image_url.startswith("data:") ): log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" @@ -1790,9 +1645,7 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in item and MediaAttr.MEDIA_IMAGE_URL in item["attributes"] - and ( - media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL] - ) + and (media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL]) and media_image_url.startswith("data:") ): item["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" From 090726792587163ef29d372bf5164937a556ab6c Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:09:27 +0200 Subject: [PATCH 40/43] Linting --- ucapi/api.py | 286 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 216 insertions(+), 70 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index 11d6c40..ffc4241 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -143,7 +143,9 @@ def _resolve_config_dir() -> str: def _voice_key(websocket: Any, session_id: int) -> VoiceSessionKey: return websocket, int(session_id) - async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = None): + async def init( + self, driver_path: str, setup_handler: uc.SetupHandler | None = None + ): """ Load driver configuration and start integration-API WebSocket server. @@ -154,7 +156,9 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N self._driver_path = driver_path self._setup_handler = setup_handler - self._configured_entities.add_listener(uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated) + self._configured_entities.add_listener( + uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated + ) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: @@ -169,13 +173,17 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N _adjust_driver_url(self._driver_info, port) - disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") + disable_mdns_publish = os.getenv( + "UC_DISABLE_MDNS_PUBLISH", "false" + ).lower() in ("true", "1") if disable_mdns_publish is False: # Setup zeroconf service info name = f'{self._driver_info["driver_id"]}._uc-integration._tcp.local.' hostname = local_hostname() - driver_name = _get_default_language_string(self._driver_info["name"], "Unknown driver") + driver_name = _get_default_language_string( + self._driver_info["name"], "Unknown driver" + ) _LOG.debug("Publishing driver: name=%s, host=%s:%d", name, hostname, port) @@ -195,7 +203,9 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N await zeroconf.async_register_service(info) host = interface if interface is not None else "0.0.0.0" - self._server_task = self._loop.create_task(self._start_web_socket_server(host, port)) + self._server_task = self._loop.create_task( + self._start_web_socket_server(host, port) + ) _LOG.info( "Driver is up: %s, version: %s, api: %s, listening on: %s:%d", @@ -213,7 +223,9 @@ async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes "attributes": attributes, } - await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) + await self._broadcast_ws_event( + uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY + ) async def _start_web_socket_server(self, host: str, port: int) -> None: async with serve(self._handle_ws, host, port): @@ -236,14 +248,22 @@ async def _handle_ws(self, websocket) -> None: try: _LOG.info("WS: Client added: %s", websocket.remote_address) - ctx.consumer_task = self._loop.create_task(self._ws_consumer(websocket, ctx)) - ctx.producer_task = self._loop.create_task(self._ws_producer(websocket, ctx)) + ctx.consumer_task = self._loop.create_task( + self._ws_consumer(websocket, ctx) + ) + ctx.producer_task = self._loop.create_task( + self._ws_producer(websocket, ctx) + ) ctx.router_task = self._loop.create_task(self._ws_router(websocket, ctx)) # authenticate on connection await self._authenticate(websocket, True) self._events.emit(uc.Events.CLIENT_CONNECTED, websocket=websocket) - tasks = [t for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] if t is not None] + tasks = [ + t + for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] + if t is not None + ] done, pending = await asyncio.wait( tasks, return_when=asyncio.FIRST_COMPLETED, @@ -254,7 +274,9 @@ async def _handle_ws(self, websocket) -> None: results = await asyncio.gather(*done, *pending, return_exceptions=True) for result in results: - if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): + if isinstance(result, Exception) and not isinstance( + result, asyncio.CancelledError + ): raise result except ConnectionClosedOK: @@ -355,11 +377,15 @@ async def _enqueue_ws_payload(self, websocket, payload: dict[str, Any]) -> None: return if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload)) + _LOG.debug( + "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload) + ) await ctx.outgoing.put(json.dumps(payload)) - async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None) -> None: + async def _send_ok_result( + self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None + ) -> None: """ Send a WebSocket success message with status code OK. @@ -370,7 +396,9 @@ async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] Raises: websockets.ConnectionClosed: When the connection is closed. """ - await self._send_ws_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) + await self._send_ws_response( + websocket, req_id, "result", msg_data, uc.StatusCodes.OK + ) async def _send_error_result( self, @@ -422,7 +450,9 @@ async def _send_ws_response( } await self._enqueue_ws_payload(websocket, data) - async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: + async def _broadcast_ws_event( + self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory + ) -> None: """ Send the given event-message to all connected WebSocket clients. @@ -438,9 +468,13 @@ async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category try: await self._enqueue_ws_payload(websocket, data) except Exception: # pylint: disable=broad-exception-caught - _LOG.exception("Failed to enqueue broadcast for %s", websocket.remote_address) + _LOG.exception( + "Failed to enqueue broadcast for %s", websocket.remote_address + ) - async def _send_ws_event(self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: + async def _send_ws_event( + self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory + ) -> None: """ Send an event-message to the given WebSocket client. @@ -572,7 +606,9 @@ async def _process_ws_binary_message(self, websocket, data: bytes) -> None: - Logs errors on deserialization failures and unknown message kinds. """ if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("[%s] <-: ", websocket.remote_address, len(data)) + _LOG.debug( + "[%s] <-: ", websocket.remote_address, len(data) + ) # Parse IntegrationMessage from bytes try: @@ -834,7 +870,9 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: # If handler not started yet, start it now (best effort) if ctx.handler_task is None and self._voice_handler is not None: try: - ctx.handler_task = self._loop.create_task(self._run_voice_handler(ctx.session)) + ctx.handler_task = self._loop.create_task( + self._run_voice_handler(ctx.session) + ) except Exception: # pylint: disable=W0718 _LOG.exception( "Failed to start voice handler on timeout for session %s", @@ -846,7 +884,9 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: await self._cleanup_voice_session(key) # pylint: disable=R0912 - async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _handle_ws_request_msg( + self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if msg == uc.WsMessages.GET_DRIVER_VERSION: await self._send_ws_response( websocket, @@ -884,7 +924,9 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat await self._unsubscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) elif msg == uc.WsMessages.GET_DRIVER_METADATA: - await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info) + await self._send_ws_response( + websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info + ) elif msg == uc.WsMessages.SETUP_DRIVER: if not await self._setup_driver(websocket, req_id, msg_data): # sleep for web-configurator quirks... @@ -895,7 +937,9 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat await asyncio.sleep(0.5) await self.driver_setup_error(websocket) - async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[str, Any] | None) -> None: + async def _handle_ws_event_msg( + self, websocket: Any, msg: str, msg_data: dict[str, Any] | None + ) -> None: if msg == uc.WsMsgEvents.CONNECT: self._events.emit(uc.Events.CONNECT, websocket=websocket) elif msg == uc.WsMsgEvents.DISCONNECT: @@ -906,7 +950,9 @@ async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[st self._events.emit(uc.Events.EXIT_STANDBY, websocket=websocket) elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP: if not self._setup_handler: - _LOG.warning("Received abort_driver_setup event, but no setup handler provided by the driver!") # noqa + _LOG.warning( + "Received abort_driver_setup event, but no setup handler provided by the driver!" + ) # noqa return if "error" in msg_data: @@ -916,7 +962,9 @@ async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[st error = uc.IntegrationSetupError.OTHER await self._setup_handler(uc.AbortDriverSetup(error)) else: - _LOG.warning("Unsupported abort_driver_setup payload received: %s", msg_data) + _LOG.warning( + "Unsupported abort_driver_setup payload received: %s", msg_data + ) async def _authenticate(self, websocket, success: bool) -> None: await self._send_ws_response( @@ -952,7 +1000,9 @@ async def set_device_state(self, state: uc.DeviceStates) -> None: uc.EventCategory.DEVICE, ) - async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> None: + async def _subscribe_events( + self, websocket: Any, msg_data: dict[str, Any] | None + ) -> None: if msg_data is None: _LOG.warning("Ignoring _subscribe_events: called with empty msg_data") return @@ -972,7 +1022,9 @@ async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | Non websocket=websocket, ) - async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> bool: + async def _unsubscribe_events( + self, websocket: Any, msg_data: dict[str, Any] | None + ) -> bool: if msg_data is None: _LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") return False @@ -991,17 +1043,23 @@ async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | N return res - async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _entity_command( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring entity command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None cmd_id = msg_data["cmd_id"] if "cmd_id" in msg_data else None if entity_id is None or cmd_id is None: _LOG.warning("Ignoring command: missing entity_id or cmd_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1064,20 +1122,28 @@ async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] "Old Entity.command signature detected for %s, trying old signature. Please update the command signature.", entity.id, ) - result = await entity.command(cmd_id, msg_data["params"] if "params" in msg_data else None) + result = await entity.command( + cmd_id, msg_data["params"] if "params" in msg_data else None + ) await self.acknowledge_command(websocket, req_id, result) - async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _browse_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring browse_media command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring browse_media command: missing entity_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1093,8 +1159,12 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | try: data = BrowseMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error("Cannot browse media for '%s': wrong format %s", entity_id, msg_data) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + _LOG.error( + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return # call integration driver to handle browse request @@ -1102,7 +1172,9 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | result = await entity.browse(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.browse for '%s'", entity_id) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.SERVER_ERROR + ) return if isinstance(result, BrowseResults): @@ -1116,16 +1188,22 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | else: await self.acknowledge_command(websocket, req_id, result) - async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _search_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring search_media command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring search_media command: missing entity_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1140,15 +1218,21 @@ async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | try: data = SearchMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error("Cannot search media for '%s': wrong format %s", entity_id, msg_data) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + _LOG.error( + "Cannot search media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return try: result = await entity.search(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.search for '%s'", entity_id) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.SERVER_ERROR + ) return if isinstance(result, SearchResults): @@ -1162,7 +1246,9 @@ async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | else: await self.acknowledge_command(websocket, req_id, result) - async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + async def _setup_driver( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> bool: await self.acknowledge_command(websocket, req_id) if msg_data is None or "setup_data" not in msg_data: @@ -1172,18 +1258,24 @@ async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | # 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!") # noqa + _LOG.error( + "Received setup_driver request, but no setup handler provided by the driver!" + ) # noqa return False result = False try: action = await self._setup_handler( - uc.DriverSetupRequest(msg_data.get("reconfigure") or False, msg_data["setup_data"]) + uc.DriverSetupRequest( + msg_data.get("reconfigure") or False, msg_data["setup_data"] + ) ) if isinstance(action, uc.RequestUserInput): await self.driver_setup_progress(websocket) - await self.request_driver_setup_user_input(websocket, action.title, action.settings) + await self.request_driver_setup_user_input( + websocket, action.title, action.settings + ) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.driver_setup_progress(websocket) @@ -1204,11 +1296,15 @@ async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | return result - async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + async def _set_driver_user_data( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> bool: await self.acknowledge_command(websocket, req_id) if not self._setup_handler: - _LOG.error("Received set_driver_user_data request, but no setup handler provided by the driver!") # noqa + _LOG.error( + "Received set_driver_user_data request, but no setup handler provided by the driver!" + ) # noqa return False if "input_values" in msg_data or "confirm" in msg_data: @@ -1216,19 +1312,27 @@ async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str await asyncio.sleep(0.5) await self.driver_setup_progress(websocket) else: - _LOG.warning("Unsupported set_driver_user_data payload received: %s", msg_data) + _LOG.warning( + "Unsupported set_driver_user_data payload received: %s", msg_data + ) return False result = False try: action = uc.SetupError() if "input_values" in msg_data: - action = await self._setup_handler(uc.UserDataResponse(msg_data["input_values"])) + action = await self._setup_handler( + uc.UserDataResponse(msg_data["input_values"]) + ) elif "confirm" in msg_data: - action = await self._setup_handler(uc.UserConfirmationResponse(msg_data["confirm"])) + action = await self._setup_handler( + uc.UserConfirmationResponse(msg_data["confirm"]) + ) if isinstance(action, uc.RequestUserInput): - await self.request_driver_setup_user_input(websocket, action.title, action.settings) + await self.request_driver_setup_user_input( + websocket, action.title, action.settings + ) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.request_driver_setup_user_confirmation( @@ -1274,7 +1378,9 @@ async def driver_setup_progress(self, websocket) -> None: """ data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) # pylint: disable=R0917 async def request_driver_setup_user_confirmation( @@ -1310,7 +1416,9 @@ async def request_driver_setup_user_confirmation( }, } - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def request_driver_setup_user_input( self, websocket, title: str | dict[str, str], settings: dict[str, Any] | list @@ -1319,22 +1427,30 @@ async def request_driver_setup_user_input( data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", - "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, + "require_user_action": { + "input": {"title": _to_language_object(title), "settings": settings} + }, } - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def driver_setup_complete(self, websocket) -> None: """Send a driver setup complete event to Remote Two/3.""" data = {"event_type": "STOP", "state": "OK"} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def driver_setup_error(self, websocket, error="OTHER") -> None: """Send a driver setup error event to Remote Two/3.""" data = {"event_type": "STOP", "state": "ERROR", "error": error} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) @staticmethod def _wrap_event_listener(listener: Callable) -> Callable: @@ -1355,7 +1471,9 @@ def _wrap_event_listener(listener: Callable) -> Callable: params = list(sig.parameters.values()) - accepts_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) + accepts_varargs = any( + p.kind == inspect.Parameter.VAR_POSITIONAL for p in params + ) accepts_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params) # How many positional args can the listener accept (excluding *args/**kwargs)? @@ -1369,13 +1487,18 @@ def _wrap_event_listener(listener: Callable) -> Callable: accepted_kw = { p.name for p in params - if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + if p.kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) } @wraps(listener) def wrapper(*args: Any, **kwargs: Any): call_args = args if accepts_varargs else args[:max_positional] - call_kwargs = kwargs if accepts_varkw else {k: v for k, v in kwargs.items() if k in accepted_kw} + call_kwargs = ( + kwargs + if accepts_varkw + else {k: v for k, v in kwargs.items() if k in accepted_kw} + ) return listener(*call_args, **call_kwargs) return wrapper @@ -1422,7 +1545,9 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: """ self._events.remove_all_listeners(event) - async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> list[str]: + async def get_supported_entity_types( + self, websocket, *, timeout: float = 5.0 + ) -> list[str]: """Request supported entity types from client and return msg_data.""" resp = await self._ws_request( websocket, @@ -1437,7 +1562,9 @@ async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) - ) return resp.get("msg_data", []) - async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: + async def get_version( + self, websocket, *, timeout: float = 5.0 + ) -> dict[str, Any] | None: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1453,7 +1580,9 @@ async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any return resp.get("msg_data") - async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: + async def get_localization_cfg( + self, websocket, *, timeout: float = 5.0 + ) -> dict[str, Any] | None: """Request localization config and return msg_data.""" resp = await self._ws_request( websocket, @@ -1470,11 +1599,15 @@ async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict return resp.get("msg_data") - async def _update_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> None: + async def _update_supported_entity_types( + self, websocket, *, timeout: float = 5.0 + ) -> None: """Update supported entity types by remote.""" await asyncio.sleep(0) try: - self._supported_entity_types = await self.get_supported_entity_types(websocket, timeout=timeout) + self._supported_entity_types = await self.get_supported_entity_types( + websocket, timeout=timeout + ) _LOG.debug( "[%s] Supported entity types %s", websocket.remote_address, @@ -1494,7 +1627,9 @@ async def _get_available_entities(self, websocket, req_id) -> None: available_entities = self._available_entities.get_all() if self._supported_entity_types: available_entities = [ - entity for entity in available_entities if entity.get("entity_type") in self._supported_entity_types + entity + for entity in available_entities + if entity.get("entity_type") in self._supported_entity_types ] await self._send_ws_response( websocket, @@ -1551,7 +1686,9 @@ def _to_language_object(text: str | dict[str, str] | None) -> dict[str, str] | N return text -def _get_default_language_string(text: str | dict[str, str] | None, default_text="Undefined") -> str: +def _get_default_language_string( + text: str | dict[str, str] | None, default_text="Undefined" +) -> str: if text is None: return default_text @@ -1611,7 +1748,10 @@ def local_hostname() -> str: # local hostname keeps on changing with a increasing number suffix! # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end - return os.getenv("UC_MDNS_LOCAL_HOSTNAME") or f'{socket.gethostname().split(".", 1)[0]}.local.' + return ( + os.getenv("UC_MDNS_LOCAL_HOSTNAME") + or f'{socket.gethostname().split(".", 1)[0]}.local.' + ) def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: @@ -1636,7 +1776,11 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in log_upd["msg_data"] and MediaAttr.MEDIA_IMAGE_URL in log_upd["msg_data"]["attributes"] - and (media_image_url := log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL]) + and ( + media_image_url := log_upd["msg_data"]["attributes"][ + MediaAttr.MEDIA_IMAGE_URL + ] + ) and media_image_url.startswith("data:") ): log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" @@ -1645,7 +1789,9 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in item and MediaAttr.MEDIA_IMAGE_URL in item["attributes"] - and (media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL]) + and ( + media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL] + ) and media_image_url.startswith("data:") ): item["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" From 649b042150de18aa21e9b50dd1dcb83c3abba444 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:53:18 +0200 Subject: [PATCH 41/43] Added requested changes --- ucapi/api.py | 307 +++++++++++++++------------------------------------ 1 file changed, 88 insertions(+), 219 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index ffc4241..eeb65a0 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -143,9 +143,7 @@ def _resolve_config_dir() -> str: def _voice_key(websocket: Any, session_id: int) -> VoiceSessionKey: return websocket, int(session_id) - async def init( - self, driver_path: str, setup_handler: uc.SetupHandler | None = None - ): + async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = None): """ Load driver configuration and start integration-API WebSocket server. @@ -156,9 +154,7 @@ async def init( self._driver_path = driver_path self._setup_handler = setup_handler - self._configured_entities.add_listener( - uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated - ) + self._configured_entities.add_listener(uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: @@ -173,17 +169,13 @@ async def init( _adjust_driver_url(self._driver_info, port) - disable_mdns_publish = os.getenv( - "UC_DISABLE_MDNS_PUBLISH", "false" - ).lower() in ("true", "1") + disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") if disable_mdns_publish is False: # Setup zeroconf service info name = f'{self._driver_info["driver_id"]}._uc-integration._tcp.local.' hostname = local_hostname() - driver_name = _get_default_language_string( - self._driver_info["name"], "Unknown driver" - ) + driver_name = _get_default_language_string(self._driver_info["name"], "Unknown driver") _LOG.debug("Publishing driver: name=%s, host=%s:%d", name, hostname, port) @@ -203,9 +195,7 @@ async def init( await zeroconf.async_register_service(info) host = interface if interface is not None else "0.0.0.0" - self._server_task = self._loop.create_task( - self._start_web_socket_server(host, port) - ) + self._server_task = self._loop.create_task(self._start_web_socket_server(host, port)) _LOG.info( "Driver is up: %s, version: %s, api: %s, listening on: %s:%d", @@ -223,9 +213,7 @@ async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes "attributes": attributes, } - await self._broadcast_ws_event( - uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY - ) + await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) async def _start_web_socket_server(self, host: str, port: int) -> None: async with serve(self._handle_ws, host, port): @@ -248,35 +236,29 @@ async def _handle_ws(self, websocket) -> None: try: _LOG.info("WS: Client added: %s", websocket.remote_address) - ctx.consumer_task = self._loop.create_task( - self._ws_consumer(websocket, ctx) - ) - ctx.producer_task = self._loop.create_task( - self._ws_producer(websocket, ctx) - ) + ctx.consumer_task = self._loop.create_task(self._ws_consumer(websocket, ctx)) + ctx.producer_task = self._loop.create_task(self._ws_producer(websocket, ctx)) ctx.router_task = self._loop.create_task(self._ws_router(websocket, ctx)) # authenticate on connection await self._authenticate(websocket, True) self._events.emit(uc.Events.CLIENT_CONNECTED, websocket=websocket) - tasks = [ - t - for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] - if t is not None - ] + tasks = [t for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] if t is not None] done, pending = await asyncio.wait( tasks, return_when=asyncio.FIRST_COMPLETED, ) + if pending: + _LOG.debug("[%s] WS: Draining tasks", websocket.remote_address) + await asyncio.wait(pending, timeout=1.0) + for task in pending: task.cancel() results = await asyncio.gather(*done, *pending, return_exceptions=True) for result in results: - if isinstance(result, Exception) and not isinstance( - result, asyncio.CancelledError - ): + if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): raise result except ConnectionClosedOK: @@ -318,7 +300,9 @@ async def _ws_consumer(self, websocket, ctx: _WsContext) -> None: ) continue - kind = data.get("kind") + kind: str | None = None + if isinstance(data, dict): + kind = data.get("kind") # Handle the response to a previous driver request if kind == "resp": @@ -377,15 +361,20 @@ async def _enqueue_ws_payload(self, websocket, payload: dict[str, Any]) -> None: return if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload) - ) - - await ctx.outgoing.put(json.dumps(payload)) + _LOG.debug("[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload)) - async def _send_ok_result( - self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None - ) -> None: + match payload.get("kind"): + case "event": + try: + ctx.outgoing.put_nowait(json.dumps(payload)) + except asyncio.QueueFull: + _LOG.warning("[%s] Outgoing queue full, dropping event", websocket.remote_address) + case "req": + ctx.outgoing.put_nowait(json.dumps(payload)) + case _: + await ctx.outgoing.put(json.dumps(payload)) + + 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. @@ -396,9 +385,7 @@ async def _send_ok_result( Raises: websockets.ConnectionClosed: When the connection is closed. """ - await self._send_ws_response( - websocket, req_id, "result", msg_data, uc.StatusCodes.OK - ) + await self._send_ws_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) async def _send_error_result( self, @@ -450,9 +437,7 @@ async def _send_ws_response( } await self._enqueue_ws_payload(websocket, data) - async def _broadcast_ws_event( - self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory - ) -> None: + async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: """ Send the given event-message to all connected WebSocket clients. @@ -468,13 +453,9 @@ async def _broadcast_ws_event( try: await self._enqueue_ws_payload(websocket, data) except Exception: # pylint: disable=broad-exception-caught - _LOG.exception( - "Failed to enqueue broadcast for %s", websocket.remote_address - ) + _LOG.exception("Failed to enqueue broadcast for %s", websocket.remote_address) - async def _send_ws_event( - self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory - ) -> None: + async def _send_ws_event(self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: """ Send an event-message to the given WebSocket client. @@ -606,9 +587,7 @@ async def _process_ws_binary_message(self, websocket, data: bytes) -> None: - Logs errors on deserialization failures and unknown message kinds. """ if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug( - "[%s] <-: ", websocket.remote_address, len(data) - ) + _LOG.debug("[%s] <-: ", websocket.remote_address, len(data)) # Parse IntegrationMessage from bytes try: @@ -870,9 +849,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: # If handler not started yet, start it now (best effort) if ctx.handler_task is None and self._voice_handler is not None: try: - ctx.handler_task = self._loop.create_task( - self._run_voice_handler(ctx.session) - ) + ctx.handler_task = self._loop.create_task(self._run_voice_handler(ctx.session)) except Exception: # pylint: disable=W0718 _LOG.exception( "Failed to start voice handler on timeout for session %s", @@ -884,9 +861,7 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: await self._cleanup_voice_session(key) # pylint: disable=R0912 - async def _handle_ws_request_msg( - self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None) -> None: if msg == uc.WsMessages.GET_DRIVER_VERSION: await self._send_ws_response( websocket, @@ -924,9 +899,7 @@ async def _handle_ws_request_msg( await self._unsubscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) elif msg == uc.WsMessages.GET_DRIVER_METADATA: - await self._send_ws_response( - websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info - ) + await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info) elif msg == uc.WsMessages.SETUP_DRIVER: if not await self._setup_driver(websocket, req_id, msg_data): # sleep for web-configurator quirks... @@ -937,9 +910,7 @@ async def _handle_ws_request_msg( await asyncio.sleep(0.5) await self.driver_setup_error(websocket) - async def _handle_ws_event_msg( - self, websocket: Any, msg: str, msg_data: dict[str, Any] | None - ) -> None: + async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[str, Any] | None) -> None: if msg == uc.WsMsgEvents.CONNECT: self._events.emit(uc.Events.CONNECT, websocket=websocket) elif msg == uc.WsMsgEvents.DISCONNECT: @@ -950,9 +921,7 @@ async def _handle_ws_event_msg( self._events.emit(uc.Events.EXIT_STANDBY, websocket=websocket) elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP: if not self._setup_handler: - _LOG.warning( - "Received abort_driver_setup event, but no setup handler provided by the driver!" - ) # noqa + _LOG.warning("Received abort_driver_setup event, but no setup handler provided by the driver!") # noqa return if "error" in msg_data: @@ -962,9 +931,7 @@ async def _handle_ws_event_msg( error = uc.IntegrationSetupError.OTHER await self._setup_handler(uc.AbortDriverSetup(error)) else: - _LOG.warning( - "Unsupported abort_driver_setup payload received: %s", msg_data - ) + _LOG.warning("Unsupported abort_driver_setup payload received: %s", msg_data) async def _authenticate(self, websocket, success: bool) -> None: await self._send_ws_response( @@ -1000,9 +967,7 @@ async def set_device_state(self, state: uc.DeviceStates) -> None: uc.EventCategory.DEVICE, ) - async def _subscribe_events( - self, websocket: Any, msg_data: dict[str, Any] | None - ) -> None: + async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> None: if msg_data is None: _LOG.warning("Ignoring _subscribe_events: called with empty msg_data") return @@ -1022,9 +987,7 @@ async def _subscribe_events( websocket=websocket, ) - async def _unsubscribe_events( - self, websocket: Any, msg_data: dict[str, Any] | None - ) -> bool: + async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> bool: if msg_data is None: _LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") return False @@ -1043,23 +1006,17 @@ async def _unsubscribe_events( return res - async def _entity_command( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring entity command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None cmd_id = msg_data["cmd_id"] if "cmd_id" in msg_data else None if entity_id is None or cmd_id is None: _LOG.warning("Ignoring command: missing entity_id or cmd_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1122,28 +1079,20 @@ async def _entity_command( "Old Entity.command signature detected for %s, trying old signature. Please update the command signature.", entity.id, ) - result = await entity.command( - cmd_id, msg_data["params"] if "params" in msg_data else None - ) + result = await entity.command(cmd_id, msg_data["params"] if "params" in msg_data else None) await self.acknowledge_command(websocket, req_id, result) - async def _browse_media( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring browse_media command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring browse_media command: missing entity_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1159,12 +1108,8 @@ async def _browse_media( try: data = BrowseMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error( - "Cannot browse media for '%s': wrong format %s", entity_id, msg_data - ) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + _LOG.error("Cannot browse media for '%s': wrong format %s", entity_id, msg_data) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return # call integration driver to handle browse request @@ -1172,9 +1117,7 @@ async def _browse_media( result = await entity.browse(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.browse for '%s'", entity_id) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.SERVER_ERROR - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) return if isinstance(result, BrowseResults): @@ -1188,22 +1131,16 @@ async def _browse_media( else: await self.acknowledge_command(websocket, req_id, result) - async def _search_media( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> None: + async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: if not msg_data: _LOG.warning("Ignoring search_media command: called with empty msg_data") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring search_media command: missing entity_id") - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return entity = self.configured_entities.get(entity_id) @@ -1218,21 +1155,15 @@ async def _search_media( try: data = SearchMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error( - "Cannot search media for '%s': wrong format %s", entity_id, msg_data - ) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.BAD_REQUEST - ) + _LOG.error("Cannot search media for '%s': wrong format %s", entity_id, msg_data) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) return try: result = await entity.search(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.search for '%s'", entity_id) - await self.acknowledge_command( - websocket, req_id, uc.StatusCodes.SERVER_ERROR - ) + await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) return if isinstance(result, SearchResults): @@ -1246,9 +1177,7 @@ async def _search_media( else: await self.acknowledge_command(websocket, req_id, result) - async def _setup_driver( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> bool: + async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: await self.acknowledge_command(websocket, req_id) if msg_data is None or "setup_data" not in msg_data: @@ -1258,24 +1187,18 @@ async def _setup_driver( # 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!" - ) # noqa + _LOG.error("Received setup_driver request, but no setup handler provided by the driver!") # noqa return False result = False try: action = await self._setup_handler( - uc.DriverSetupRequest( - msg_data.get("reconfigure") or False, msg_data["setup_data"] - ) + uc.DriverSetupRequest(msg_data.get("reconfigure") or False, msg_data["setup_data"]) ) if isinstance(action, uc.RequestUserInput): await self.driver_setup_progress(websocket) - await self.request_driver_setup_user_input( - websocket, action.title, action.settings - ) + await self.request_driver_setup_user_input(websocket, action.title, action.settings) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.driver_setup_progress(websocket) @@ -1296,15 +1219,11 @@ async def _setup_driver( return result - async def _set_driver_user_data( - self, websocket, req_id: int, msg_data: dict[str, Any] | None - ) -> bool: + async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: await self.acknowledge_command(websocket, req_id) if not self._setup_handler: - _LOG.error( - "Received set_driver_user_data request, but no setup handler provided by the driver!" - ) # noqa + _LOG.error("Received set_driver_user_data request, but no setup handler provided by the driver!") # noqa return False if "input_values" in msg_data or "confirm" in msg_data: @@ -1312,27 +1231,19 @@ async def _set_driver_user_data( await asyncio.sleep(0.5) await self.driver_setup_progress(websocket) else: - _LOG.warning( - "Unsupported set_driver_user_data payload received: %s", msg_data - ) + _LOG.warning("Unsupported set_driver_user_data payload received: %s", msg_data) return False result = False try: action = uc.SetupError() if "input_values" in msg_data: - action = await self._setup_handler( - uc.UserDataResponse(msg_data["input_values"]) - ) + action = await self._setup_handler(uc.UserDataResponse(msg_data["input_values"])) elif "confirm" in msg_data: - action = await self._setup_handler( - uc.UserConfirmationResponse(msg_data["confirm"]) - ) + action = await self._setup_handler(uc.UserConfirmationResponse(msg_data["confirm"])) if isinstance(action, uc.RequestUserInput): - await self.request_driver_setup_user_input( - websocket, action.title, action.settings - ) + await self.request_driver_setup_user_input(websocket, action.title, action.settings) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.request_driver_setup_user_confirmation( @@ -1378,9 +1289,7 @@ async def driver_setup_progress(self, websocket) -> None: """ data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) # pylint: disable=R0917 async def request_driver_setup_user_confirmation( @@ -1416,9 +1325,7 @@ async def request_driver_setup_user_confirmation( }, } - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def request_driver_setup_user_input( self, websocket, title: str | dict[str, str], settings: dict[str, Any] | list @@ -1427,30 +1334,22 @@ async def request_driver_setup_user_input( data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", - "require_user_action": { - "input": {"title": _to_language_object(title), "settings": settings} - }, + "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, } - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def driver_setup_complete(self, websocket) -> None: """Send a driver setup complete event to Remote Two/3.""" data = {"event_type": "STOP", "state": "OK"} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) async def driver_setup_error(self, websocket, error="OTHER") -> None: """Send a driver setup error event to Remote Two/3.""" data = {"event_type": "STOP", "state": "ERROR", "error": error} - await self._send_ws_event( - websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE - ) + await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) @staticmethod def _wrap_event_listener(listener: Callable) -> Callable: @@ -1471,9 +1370,7 @@ def _wrap_event_listener(listener: Callable) -> Callable: params = list(sig.parameters.values()) - accepts_varargs = any( - p.kind == inspect.Parameter.VAR_POSITIONAL for p in params - ) + accepts_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) accepts_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params) # How many positional args can the listener accept (excluding *args/**kwargs)? @@ -1487,18 +1384,13 @@ def _wrap_event_listener(listener: Callable) -> Callable: accepted_kw = { p.name for p in params - if p.kind - in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) } @wraps(listener) def wrapper(*args: Any, **kwargs: Any): call_args = args if accepts_varargs else args[:max_positional] - call_kwargs = ( - kwargs - if accepts_varkw - else {k: v for k, v in kwargs.items() if k in accepted_kw} - ) + call_kwargs = kwargs if accepts_varkw else {k: v for k, v in kwargs.items() if k in accepted_kw} return listener(*call_args, **call_kwargs) return wrapper @@ -1545,9 +1437,7 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: """ self._events.remove_all_listeners(event) - async def get_supported_entity_types( - self, websocket, *, timeout: float = 5.0 - ) -> list[str]: + async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> list[str]: """Request supported entity types from client and return msg_data.""" resp = await self._ws_request( websocket, @@ -1562,9 +1452,7 @@ async def get_supported_entity_types( ) return resp.get("msg_data", []) - async def get_version( - self, websocket, *, timeout: float = 5.0 - ) -> dict[str, Any] | None: + async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1580,9 +1468,7 @@ async def get_version( return resp.get("msg_data") - async def get_localization_cfg( - self, websocket, *, timeout: float = 5.0 - ) -> dict[str, Any] | None: + async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: """Request localization config and return msg_data.""" resp = await self._ws_request( websocket, @@ -1599,15 +1485,11 @@ async def get_localization_cfg( return resp.get("msg_data") - async def _update_supported_entity_types( - self, websocket, *, timeout: float = 5.0 - ) -> None: + async def _update_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> None: """Update supported entity types by remote.""" await asyncio.sleep(0) try: - self._supported_entity_types = await self.get_supported_entity_types( - websocket, timeout=timeout - ) + self._supported_entity_types = await self.get_supported_entity_types(websocket, timeout=timeout) _LOG.debug( "[%s] Supported entity types %s", websocket.remote_address, @@ -1627,9 +1509,7 @@ async def _get_available_entities(self, websocket, req_id) -> None: available_entities = self._available_entities.get_all() if self._supported_entity_types: available_entities = [ - entity - for entity in available_entities - if entity.get("entity_type") in self._supported_entity_types + entity for entity in available_entities if entity.get("entity_type") in self._supported_entity_types ] await self._send_ws_response( websocket, @@ -1686,9 +1566,7 @@ def _to_language_object(text: str | dict[str, str] | None) -> dict[str, str] | N return text -def _get_default_language_string( - text: str | dict[str, str] | None, default_text="Undefined" -) -> str: +def _get_default_language_string(text: str | dict[str, str] | None, default_text="Undefined") -> str: if text is None: return default_text @@ -1748,10 +1626,7 @@ def local_hostname() -> str: # local hostname keeps on changing with a increasing number suffix! # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end - return ( - os.getenv("UC_MDNS_LOCAL_HOSTNAME") - or f'{socket.gethostname().split(".", 1)[0]}.local.' - ) + return os.getenv("UC_MDNS_LOCAL_HOSTNAME") or f'{socket.gethostname().split(".", 1)[0]}.local.' def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: @@ -1776,11 +1651,7 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in log_upd["msg_data"] and MediaAttr.MEDIA_IMAGE_URL in log_upd["msg_data"]["attributes"] - and ( - media_image_url := log_upd["msg_data"]["attributes"][ - MediaAttr.MEDIA_IMAGE_URL - ] - ) + and (media_image_url := log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL]) and media_image_url.startswith("data:") ): log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" @@ -1789,9 +1660,7 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in item and MediaAttr.MEDIA_IMAGE_URL in item["attributes"] - and ( - media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL] - ) + and (media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL]) and media_image_url.startswith("data:") ): item["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" From afb416b59622f5474eeec57355b1473cfebb1928 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:55:15 +0200 Subject: [PATCH 42/43] Linting --- ucapi/api.py | 291 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 220 insertions(+), 71 deletions(-) diff --git a/ucapi/api.py b/ucapi/api.py index eeb65a0..b1b0911 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -143,7 +143,9 @@ def _resolve_config_dir() -> str: def _voice_key(websocket: Any, session_id: int) -> VoiceSessionKey: return websocket, int(session_id) - async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = None): + async def init( + self, driver_path: str, setup_handler: uc.SetupHandler | None = None + ): """ Load driver configuration and start integration-API WebSocket server. @@ -154,7 +156,9 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N self._driver_path = driver_path self._setup_handler = setup_handler - self._configured_entities.add_listener(uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated) + self._configured_entities.add_listener( + uc.Events.ENTITY_ATTRIBUTES_UPDATED, self._on_entity_attributes_updated + ) # Load driver config with open(self._driver_path, "r", encoding="utf-8") as file: @@ -169,13 +173,17 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N _adjust_driver_url(self._driver_info, port) - disable_mdns_publish = os.getenv("UC_DISABLE_MDNS_PUBLISH", "false").lower() in ("true", "1") + disable_mdns_publish = os.getenv( + "UC_DISABLE_MDNS_PUBLISH", "false" + ).lower() in ("true", "1") if disable_mdns_publish is False: # Setup zeroconf service info name = f'{self._driver_info["driver_id"]}._uc-integration._tcp.local.' hostname = local_hostname() - driver_name = _get_default_language_string(self._driver_info["name"], "Unknown driver") + driver_name = _get_default_language_string( + self._driver_info["name"], "Unknown driver" + ) _LOG.debug("Publishing driver: name=%s, host=%s:%d", name, hostname, port) @@ -195,7 +203,9 @@ async def init(self, driver_path: str, setup_handler: uc.SetupHandler | None = N await zeroconf.async_register_service(info) host = interface if interface is not None else "0.0.0.0" - self._server_task = self._loop.create_task(self._start_web_socket_server(host, port)) + self._server_task = self._loop.create_task( + self._start_web_socket_server(host, port) + ) _LOG.info( "Driver is up: %s, version: %s, api: %s, listening on: %s:%d", @@ -213,7 +223,9 @@ async def _on_entity_attributes_updated(self, entity_id, entity_type, attributes "attributes": attributes, } - await self._broadcast_ws_event(uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY) + await self._broadcast_ws_event( + uc.WsMsgEvents.ENTITY_CHANGE, data, uc.EventCategory.ENTITY + ) async def _start_web_socket_server(self, host: str, port: int) -> None: async with serve(self._handle_ws, host, port): @@ -236,14 +248,22 @@ async def _handle_ws(self, websocket) -> None: try: _LOG.info("WS: Client added: %s", websocket.remote_address) - ctx.consumer_task = self._loop.create_task(self._ws_consumer(websocket, ctx)) - ctx.producer_task = self._loop.create_task(self._ws_producer(websocket, ctx)) + ctx.consumer_task = self._loop.create_task( + self._ws_consumer(websocket, ctx) + ) + ctx.producer_task = self._loop.create_task( + self._ws_producer(websocket, ctx) + ) ctx.router_task = self._loop.create_task(self._ws_router(websocket, ctx)) # authenticate on connection await self._authenticate(websocket, True) self._events.emit(uc.Events.CLIENT_CONNECTED, websocket=websocket) - tasks = [t for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] if t is not None] + tasks = [ + t + for t in [ctx.consumer_task, ctx.producer_task, ctx.router_task] + if t is not None + ] done, pending = await asyncio.wait( tasks, return_when=asyncio.FIRST_COMPLETED, @@ -258,7 +278,9 @@ async def _handle_ws(self, websocket) -> None: results = await asyncio.gather(*done, *pending, return_exceptions=True) for result in results: - if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): + if isinstance(result, Exception) and not isinstance( + result, asyncio.CancelledError + ): raise result except ConnectionClosedOK: @@ -361,20 +383,27 @@ async def _enqueue_ws_payload(self, websocket, payload: dict[str, Any]) -> None: return if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload)) + _LOG.debug( + "[%s] ->: %s", websocket.remote_address, filter_log_msg_data(payload) + ) match payload.get("kind"): case "event": try: ctx.outgoing.put_nowait(json.dumps(payload)) except asyncio.QueueFull: - _LOG.warning("[%s] Outgoing queue full, dropping event", websocket.remote_address) + _LOG.warning( + "[%s] Outgoing queue full, dropping event", + websocket.remote_address, + ) case "req": ctx.outgoing.put_nowait(json.dumps(payload)) case _: await ctx.outgoing.put(json.dumps(payload)) - async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None) -> None: + async def _send_ok_result( + self, websocket, req_id: int, msg_data: dict[str, Any] | list | None = None + ) -> None: """ Send a WebSocket success message with status code OK. @@ -385,7 +414,9 @@ async def _send_ok_result(self, websocket, req_id: int, msg_data: dict[str, Any] Raises: websockets.ConnectionClosed: When the connection is closed. """ - await self._send_ws_response(websocket, req_id, "result", msg_data, uc.StatusCodes.OK) + await self._send_ws_response( + websocket, req_id, "result", msg_data, uc.StatusCodes.OK + ) async def _send_error_result( self, @@ -437,7 +468,9 @@ async def _send_ws_response( } await self._enqueue_ws_payload(websocket, data) - async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: + async def _broadcast_ws_event( + self, msg: str, msg_data: dict[str, Any], category: uc.EventCategory + ) -> None: """ Send the given event-message to all connected WebSocket clients. @@ -453,9 +486,13 @@ async def _broadcast_ws_event(self, msg: str, msg_data: dict[str, Any], category try: await self._enqueue_ws_payload(websocket, data) except Exception: # pylint: disable=broad-exception-caught - _LOG.exception("Failed to enqueue broadcast for %s", websocket.remote_address) + _LOG.exception( + "Failed to enqueue broadcast for %s", websocket.remote_address + ) - async def _send_ws_event(self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory) -> None: + async def _send_ws_event( + self, websocket, msg: str, msg_data: dict[str, Any], category: uc.EventCategory + ) -> None: """ Send an event-message to the given WebSocket client. @@ -587,7 +624,9 @@ async def _process_ws_binary_message(self, websocket, data: bytes) -> None: - Logs errors on deserialization failures and unknown message kinds. """ if _LOG.isEnabledFor(logging.DEBUG): - _LOG.debug("[%s] <-: ", websocket.remote_address, len(data)) + _LOG.debug( + "[%s] <-: ", websocket.remote_address, len(data) + ) # Parse IntegrationMessage from bytes try: @@ -849,7 +888,9 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: # If handler not started yet, start it now (best effort) if ctx.handler_task is None and self._voice_handler is not None: try: - ctx.handler_task = self._loop.create_task(self._run_voice_handler(ctx.session)) + ctx.handler_task = self._loop.create_task( + self._run_voice_handler(ctx.session) + ) except Exception: # pylint: disable=W0718 _LOG.exception( "Failed to start voice handler on timeout for session %s", @@ -861,7 +902,9 @@ async def _voice_session_timeout_task(self, key: VoiceSessionKey) -> None: await self._cleanup_voice_session(key) # pylint: disable=R0912 - async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _handle_ws_request_msg( + self, websocket, msg: str, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if msg == uc.WsMessages.GET_DRIVER_VERSION: await self._send_ws_response( websocket, @@ -899,7 +942,9 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat await self._unsubscribe_events(websocket, msg_data) await self._send_ok_result(websocket, req_id) elif msg == uc.WsMessages.GET_DRIVER_METADATA: - await self._send_ws_response(websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info) + await self._send_ws_response( + websocket, req_id, uc.WsMsgEvents.DRIVER_METADATA, self._driver_info + ) elif msg == uc.WsMessages.SETUP_DRIVER: if not await self._setup_driver(websocket, req_id, msg_data): # sleep for web-configurator quirks... @@ -910,7 +955,9 @@ async def _handle_ws_request_msg(self, websocket, msg: str, req_id: int, msg_dat await asyncio.sleep(0.5) await self.driver_setup_error(websocket) - async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[str, Any] | None) -> None: + async def _handle_ws_event_msg( + self, websocket: Any, msg: str, msg_data: dict[str, Any] | None + ) -> None: if msg == uc.WsMsgEvents.CONNECT: self._events.emit(uc.Events.CONNECT, websocket=websocket) elif msg == uc.WsMsgEvents.DISCONNECT: @@ -921,7 +968,9 @@ async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[st self._events.emit(uc.Events.EXIT_STANDBY, websocket=websocket) elif msg == uc.WsMsgEvents.ABORT_DRIVER_SETUP: if not self._setup_handler: - _LOG.warning("Received abort_driver_setup event, but no setup handler provided by the driver!") # noqa + _LOG.warning( + "Received abort_driver_setup event, but no setup handler provided by the driver!" + ) # noqa return if "error" in msg_data: @@ -931,7 +980,9 @@ async def _handle_ws_event_msg(self, websocket: Any, msg: str, msg_data: dict[st error = uc.IntegrationSetupError.OTHER await self._setup_handler(uc.AbortDriverSetup(error)) else: - _LOG.warning("Unsupported abort_driver_setup payload received: %s", msg_data) + _LOG.warning( + "Unsupported abort_driver_setup payload received: %s", msg_data + ) async def _authenticate(self, websocket, success: bool) -> None: await self._send_ws_response( @@ -967,7 +1018,9 @@ async def set_device_state(self, state: uc.DeviceStates) -> None: uc.EventCategory.DEVICE, ) - async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> None: + async def _subscribe_events( + self, websocket: Any, msg_data: dict[str, Any] | None + ) -> None: if msg_data is None: _LOG.warning("Ignoring _subscribe_events: called with empty msg_data") return @@ -987,7 +1040,9 @@ async def _subscribe_events(self, websocket: Any, msg_data: dict[str, Any] | Non websocket=websocket, ) - async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | None) -> bool: + async def _unsubscribe_events( + self, websocket: Any, msg_data: dict[str, Any] | None + ) -> bool: if msg_data is None: _LOG.warning("Ignoring _unsubscribe_events: called with empty msg_data") return False @@ -1006,17 +1061,23 @@ async def _unsubscribe_events(self, websocket: Any, msg_data: dict[str, Any] | N return res - async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _entity_command( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring entity command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None cmd_id = msg_data["cmd_id"] if "cmd_id" in msg_data else None if entity_id is None or cmd_id is None: _LOG.warning("Ignoring command: missing entity_id or cmd_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1079,20 +1140,28 @@ async def _entity_command(self, websocket, req_id: int, msg_data: dict[str, Any] "Old Entity.command signature detected for %s, trying old signature. Please update the command signature.", entity.id, ) - result = await entity.command(cmd_id, msg_data["params"] if "params" in msg_data else None) + result = await entity.command( + cmd_id, msg_data["params"] if "params" in msg_data else None + ) await self.acknowledge_command(websocket, req_id, result) - async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _browse_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring browse_media command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring browse_media command: missing entity_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1108,8 +1177,12 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | try: data = BrowseMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error("Cannot browse media for '%s': wrong format %s", entity_id, msg_data) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + _LOG.error( + "Cannot browse media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return # call integration driver to handle browse request @@ -1117,7 +1190,9 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | result = await entity.browse(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.browse for '%s'", entity_id) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.SERVER_ERROR + ) return if isinstance(result, BrowseResults): @@ -1131,16 +1206,22 @@ async def _browse_media(self, websocket, req_id: int, msg_data: dict[str, Any] | else: await self.acknowledge_command(websocket, req_id, result) - async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> None: + async def _search_media( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> None: if not msg_data: _LOG.warning("Ignoring search_media command: called with empty msg_data") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity_id = msg_data["entity_id"] if "entity_id" in msg_data else None if entity_id is None: _LOG.warning("Ignoring search_media command: missing entity_id") - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return entity = self.configured_entities.get(entity_id) @@ -1155,15 +1236,21 @@ async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | try: data = SearchMediaMsgData(**msg_data) except (TypeError, ValueError): - _LOG.error("Cannot search media for '%s': wrong format %s", entity_id, msg_data) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.BAD_REQUEST) + _LOG.error( + "Cannot search media for '%s': wrong format %s", entity_id, msg_data + ) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.BAD_REQUEST + ) return try: result = await entity.search(data) except Exception: # pylint: disable=W0718 _LOG.exception("Failed to call MediaPlayer.search for '%s'", entity_id) - await self.acknowledge_command(websocket, req_id, uc.StatusCodes.SERVER_ERROR) + await self.acknowledge_command( + websocket, req_id, uc.StatusCodes.SERVER_ERROR + ) return if isinstance(result, SearchResults): @@ -1177,7 +1264,9 @@ async def _search_media(self, websocket, req_id: int, msg_data: dict[str, Any] | else: await self.acknowledge_command(websocket, req_id, result) - async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + async def _setup_driver( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> bool: await self.acknowledge_command(websocket, req_id) if msg_data is None or "setup_data" not in msg_data: @@ -1187,18 +1276,24 @@ async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | # 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!") # noqa + _LOG.error( + "Received setup_driver request, but no setup handler provided by the driver!" + ) # noqa return False result = False try: action = await self._setup_handler( - uc.DriverSetupRequest(msg_data.get("reconfigure") or False, msg_data["setup_data"]) + uc.DriverSetupRequest( + msg_data.get("reconfigure") or False, msg_data["setup_data"] + ) ) if isinstance(action, uc.RequestUserInput): await self.driver_setup_progress(websocket) - await self.request_driver_setup_user_input(websocket, action.title, action.settings) + await self.request_driver_setup_user_input( + websocket, action.title, action.settings + ) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.driver_setup_progress(websocket) @@ -1219,11 +1314,15 @@ async def _setup_driver(self, websocket, req_id: int, msg_data: dict[str, Any] | return result - async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str, Any] | None) -> bool: + async def _set_driver_user_data( + self, websocket, req_id: int, msg_data: dict[str, Any] | None + ) -> bool: await self.acknowledge_command(websocket, req_id) if not self._setup_handler: - _LOG.error("Received set_driver_user_data request, but no setup handler provided by the driver!") # noqa + _LOG.error( + "Received set_driver_user_data request, but no setup handler provided by the driver!" + ) # noqa return False if "input_values" in msg_data or "confirm" in msg_data: @@ -1231,19 +1330,27 @@ async def _set_driver_user_data(self, websocket, req_id: int, msg_data: dict[str await asyncio.sleep(0.5) await self.driver_setup_progress(websocket) else: - _LOG.warning("Unsupported set_driver_user_data payload received: %s", msg_data) + _LOG.warning( + "Unsupported set_driver_user_data payload received: %s", msg_data + ) return False result = False try: action = uc.SetupError() if "input_values" in msg_data: - action = await self._setup_handler(uc.UserDataResponse(msg_data["input_values"])) + action = await self._setup_handler( + uc.UserDataResponse(msg_data["input_values"]) + ) elif "confirm" in msg_data: - action = await self._setup_handler(uc.UserConfirmationResponse(msg_data["confirm"])) + action = await self._setup_handler( + uc.UserConfirmationResponse(msg_data["confirm"]) + ) if isinstance(action, uc.RequestUserInput): - await self.request_driver_setup_user_input(websocket, action.title, action.settings) + await self.request_driver_setup_user_input( + websocket, action.title, action.settings + ) result = True elif isinstance(action, uc.RequestUserConfirmation): await self.request_driver_setup_user_confirmation( @@ -1289,7 +1396,9 @@ async def driver_setup_progress(self, websocket) -> None: """ data = {"event_type": "SETUP", "state": "SETUP"} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) # pylint: disable=R0917 async def request_driver_setup_user_confirmation( @@ -1325,7 +1434,9 @@ async def request_driver_setup_user_confirmation( }, } - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def request_driver_setup_user_input( self, websocket, title: str | dict[str, str], settings: dict[str, Any] | list @@ -1334,22 +1445,30 @@ async def request_driver_setup_user_input( data = { "event_type": "SETUP", "state": "WAIT_USER_ACTION", - "require_user_action": {"input": {"title": _to_language_object(title), "settings": settings}}, + "require_user_action": { + "input": {"title": _to_language_object(title), "settings": settings} + }, } - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def driver_setup_complete(self, websocket) -> None: """Send a driver setup complete event to Remote Two/3.""" data = {"event_type": "STOP", "state": "OK"} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) async def driver_setup_error(self, websocket, error="OTHER") -> None: """Send a driver setup error event to Remote Two/3.""" data = {"event_type": "STOP", "state": "ERROR", "error": error} - await self._send_ws_event(websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE) + await self._send_ws_event( + websocket, uc.WsMsgEvents.DRIVER_SETUP_CHANGE, data, uc.EventCategory.DEVICE + ) @staticmethod def _wrap_event_listener(listener: Callable) -> Callable: @@ -1370,7 +1489,9 @@ def _wrap_event_listener(listener: Callable) -> Callable: params = list(sig.parameters.values()) - accepts_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params) + accepts_varargs = any( + p.kind == inspect.Parameter.VAR_POSITIONAL for p in params + ) accepts_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params) # How many positional args can the listener accept (excluding *args/**kwargs)? @@ -1384,13 +1505,18 @@ def _wrap_event_listener(listener: Callable) -> Callable: accepted_kw = { p.name for p in params - if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) + if p.kind + in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY) } @wraps(listener) def wrapper(*args: Any, **kwargs: Any): call_args = args if accepts_varargs else args[:max_positional] - call_kwargs = kwargs if accepts_varkw else {k: v for k, v in kwargs.items() if k in accepted_kw} + call_kwargs = ( + kwargs + if accepts_varkw + else {k: v for k, v in kwargs.items() if k in accepted_kw} + ) return listener(*call_args, **call_kwargs) return wrapper @@ -1437,7 +1563,9 @@ def remove_all_listeners(self, event: uc.Events | None) -> None: """ self._events.remove_all_listeners(event) - async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> list[str]: + async def get_supported_entity_types( + self, websocket, *, timeout: float = 5.0 + ) -> list[str]: """Request supported entity types from client and return msg_data.""" resp = await self._ws_request( websocket, @@ -1452,7 +1580,9 @@ async def get_supported_entity_types(self, websocket, *, timeout: float = 5.0) - ) return resp.get("msg_data", []) - async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: + async def get_version( + self, websocket, *, timeout: float = 5.0 + ) -> dict[str, Any] | None: """Request client version and return msg_data.""" resp = await self._ws_request( websocket, @@ -1468,7 +1598,9 @@ async def get_version(self, websocket, *, timeout: float = 5.0) -> dict[str, Any return resp.get("msg_data") - async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict[str, Any] | None: + async def get_localization_cfg( + self, websocket, *, timeout: float = 5.0 + ) -> dict[str, Any] | None: """Request localization config and return msg_data.""" resp = await self._ws_request( websocket, @@ -1485,11 +1617,15 @@ async def get_localization_cfg(self, websocket, *, timeout: float = 5.0) -> dict return resp.get("msg_data") - async def _update_supported_entity_types(self, websocket, *, timeout: float = 5.0) -> None: + async def _update_supported_entity_types( + self, websocket, *, timeout: float = 5.0 + ) -> None: """Update supported entity types by remote.""" await asyncio.sleep(0) try: - self._supported_entity_types = await self.get_supported_entity_types(websocket, timeout=timeout) + self._supported_entity_types = await self.get_supported_entity_types( + websocket, timeout=timeout + ) _LOG.debug( "[%s] Supported entity types %s", websocket.remote_address, @@ -1509,7 +1645,9 @@ async def _get_available_entities(self, websocket, req_id) -> None: available_entities = self._available_entities.get_all() if self._supported_entity_types: available_entities = [ - entity for entity in available_entities if entity.get("entity_type") in self._supported_entity_types + entity + for entity in available_entities + if entity.get("entity_type") in self._supported_entity_types ] await self._send_ws_response( websocket, @@ -1566,7 +1704,9 @@ def _to_language_object(text: str | dict[str, str] | None) -> dict[str, str] | N return text -def _get_default_language_string(text: str | dict[str, str] | None, default_text="Undefined") -> str: +def _get_default_language_string( + text: str | dict[str, str] | None, default_text="Undefined" +) -> str: if text is None: return default_text @@ -1626,7 +1766,10 @@ def local_hostname() -> str: # local hostname keeps on changing with a increasing number suffix! # https://apple.stackexchange.com/questions/189350/my-macs-hostname-keeps-adding-a-2-to-the-end - return os.getenv("UC_MDNS_LOCAL_HOSTNAME") or f'{socket.gethostname().split(".", 1)[0]}.local.' + return ( + os.getenv("UC_MDNS_LOCAL_HOSTNAME") + or f'{socket.gethostname().split(".", 1)[0]}.local.' + ) def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: @@ -1651,7 +1794,11 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in log_upd["msg_data"] and MediaAttr.MEDIA_IMAGE_URL in log_upd["msg_data"]["attributes"] - and (media_image_url := log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL]) + and ( + media_image_url := log_upd["msg_data"]["attributes"][ + MediaAttr.MEDIA_IMAGE_URL + ] + ) and media_image_url.startswith("data:") ): log_upd["msg_data"]["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" @@ -1660,7 +1807,9 @@ def filter_log_msg_data(data: dict[str, Any]) -> dict[str, Any]: if ( "attributes" in item and MediaAttr.MEDIA_IMAGE_URL in item["attributes"] - and (media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL]) + and ( + media_image_url := item["attributes"][MediaAttr.MEDIA_IMAGE_URL] + ) and media_image_url.startswith("data:") ): item["attributes"][MediaAttr.MEDIA_IMAGE_URL] = "data:***" From ac2bc4c49b939975d9ca85a3f8bdc1c6f080440b Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Thu, 23 Apr 2026 10:07:32 +0200 Subject: [PATCH 43/43] refactor: log all exceptions when terminating WS tasks --- ucapi/api.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ucapi/api.py b/ucapi/api.py index b1b0911..47586e4 100644 --- a/ucapi/api.py +++ b/ucapi/api.py @@ -270,6 +270,7 @@ async def _handle_ws(self, websocket) -> None: ) if pending: + # graceful shutdown: wait a bit for pending tasks to process sentinel 'None' _LOG.debug("[%s] WS: Draining tasks", websocket.remote_address) await asyncio.wait(pending, timeout=1.0) @@ -281,7 +282,11 @@ async def _handle_ws(self, websocket) -> None: if isinstance(result, Exception) and not isinstance( result, asyncio.CancelledError ): - raise result + _LOG.error( + "[%s] WS: Exception in task", + websocket.remote_address, + exc_info=result, + ) except ConnectionClosedOK: _LOG.info("[%s] WS: Connection closed", websocket.remote_address)