Skip to content
Merged
Changes from 28 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
d6c5611
feat: Add requests for supported entity types, version and localization
kennymc-c Jan 31, 2026
df90062
Working handle of websocket requests from ucapi
albaintor Feb 1, 2026
b4de43b
Replaced exception with error trace
albaintor Feb 1, 2026
a139fed
Handle exception
albaintor Feb 1, 2026
fe8f47a
Fixed stacktrace error, all good now
albaintor Feb 2, 2026
c0b802b
Fixed blocking websocket requests : create task for each message to h…
albaintor Feb 3, 2026
e6e7cba
Removed response signatures
albaintor Feb 3, 2026
d0eae3c
Removed response signatures
albaintor Feb 3, 2026
4a9e175
Check after supported entity types
albaintor Feb 3, 2026
110d147
Moved extraction of supported entity types in request for available e…
damienbto Feb 4, 2026
e80291d
Linting
damienbto Feb 4, 2026
7a3e8a7
Requested changes
albaintor Feb 12, 2026
5e1ab8c
Linting and removed comment
albaintor Feb 13, 2026
4a413fc
Linting flake8
albaintor Feb 13, 2026
0caacfb
Merge branch 'main' into websocket_requests
zehnm Feb 18, 2026
335b9a3
fix: merge from main
zehnm Feb 18, 2026
55b4c45
Added media browsing request
albaintor Mar 4, 2026
5140403
Added missing methods and commands
albaintor Mar 8, 2026
6d0a3a5
Fixed search media response type
albaintor Mar 11, 2026
c1c4d9b
Merged new methods
albaintor Mar 13, 2026
489a789
Merge branch 'websocket_requests' of https://github.com/albaintor/int…
albaintor Mar 13, 2026
22b01ee
Added clients extraction (hack). To be improved
albaintor Mar 13, 2026
a4bbcf6
Create task is necessary to avoid blocking
albaintor Mar 13, 2026
e55a4b4
Refactoring and added typed definitions
albaintor Mar 19, 2026
69ab522
Finalized & tested updated API with browsing/search support
albaintor Mar 19, 2026
0665b31
Removed forced media_type to MediaContentType as it can be custom
albaintor Mar 19, 2026
8f9f553
Fixed bug
albaintor Mar 19, 2026
7c0e585
Fixed bug
albaintor Mar 19, 2026
0081b3d
Nailed bug finally
albaintor Mar 19, 2026
4c16fbf
Fixes reported by Jack
albaintor Mar 19, 2026
d11cfea
Fixes following Markus review
albaintor Mar 20, 2026
de39fa7
Merge branch 'media_browsing' into websocket_requests
albaintor Mar 20, 2026
7f015ba
Merge branch 'main' into websocket_requests
zehnm Mar 27, 2026
2f02593
merge cleanup
zehnm Mar 27, 2026
ab21f22
clean up comments
zehnm Mar 27, 2026
f83a0f1
no task required for _process_ws_binary_message
zehnm Mar 27, 2026
b9d3012
clean up: remove _supported_entity_types
zehnm Mar 27, 2026
4ad5e15
Merge remote-tracking branch 'upstream/main' into websocket_requests
albaintor Apr 11, 2026
9c41ae7
Changed code with queues management
albaintor Apr 11, 2026
58fd854
Merge branch 'websocket_requests' of https://github.com/albaintor/int…
albaintor Apr 11, 2026
c47b9ef
Removed unecessary files
albaintor Apr 11, 2026
3fb765e
Reverted unwanted changes
albaintor Apr 11, 2026
8ca4a4a
..
albaintor Apr 11, 2026
48d6aee
Handle of requests/response inside requests
albaintor Apr 11, 2026
d62dbe7
Linting
albaintor Apr 21, 2026
4914534
Linting
albaintor Apr 21, 2026
0907267
Linting
albaintor Apr 21, 2026
649b042
Added requested changes
albaintor Apr 23, 2026
afb416b
Linting
albaintor Apr 23, 2026
ac2bc4c
refactor: log all exceptions when terminating WS tasks
zehnm Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 168 additions & 4 deletions ucapi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,18 @@ 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
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]] = {}

# Setup event loop
asyncio.set_event_loop(self._loop)

Expand Down Expand Up @@ -216,6 +221,8 @@ 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
self._ws_pending[websocket] = {}
_LOG.info("WS: Client added: %s", websocket.remote_address)

# authenticate on connection
Expand All @@ -227,9 +234,9 @@ 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After further consideration, creating a dedicated task per received JSON message is not a good solution and can create more issues than it solves.

  • Increases concurrency without a bound.
  • That makes message ordering non-deterministic. Especially for the press-and-hold key commands and events this could easily introduce hard to trace bugs.
  • Multiple tasks are accessing the shared connection state in the class, easily creating more issues.

I suggest a proper solution with a consumer/producer model with queues. One task reads from the socket and enqueues messages, one task writes outbound messages, and worker tasks or inline logic process messages from the queue. This keeps websocket access centralized, gives you explicit backpressure, and makes ordering and shutdown behavior much easier to control.

A simple demo implementation based on the Websockets library documentation https://websockets.readthedocs.io/en/15.0.1/howto/patterns.html

import asyncio
from websockets import serve
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError

async def consumer(ws, incoming: asyncio.Queue):
    try:
        async for message in ws:
            await incoming.put(message)
    finally:
        await incoming.put(None)  # sentinel

async def producer(ws, outgoing: asyncio.Queue):
    try:
        while True:
            msg = await outgoing.get()
            if msg is None:
                break
            await ws.send(msg)
    except (ConnectionClosedOK, ConnectionClosedError):
        pass

async def router(incoming: asyncio.Queue, outgoing: asyncio.Queue):
    while True:
        msg = await incoming.get()
        if msg is None:
            break
        # process message (possibly slow)
        reply = await process_message(msg)
        # enqueue reply without directly touching ws
        await outgoing.put(reply)

async def handler(ws):
    incoming = asyncio.Queue()
    outgoing = asyncio.Queue()

    tasks = [
        asyncio.create_task(consumer(ws, incoming)),
        asyncio.create_task(producer(ws, outgoing)),
        asyncio.create_task(router(incoming, outgoing)),
    ]

    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
    for t in pending:
        t.cancel()

elif isinstance(message, (bytes, bytearray, memoryview)):
# Binary message (protobuf in future)
# Binary message (protobuf)
await self._process_ws_binary_message(websocket, bytes(message))
else:
_LOG.warning(
Expand Down Expand Up @@ -270,7 +277,11 @@ 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._clients.remove(websocket)
_LOG.info("[%s] WS: Client removed", websocket.remote_address)
self._events.emit(uc.Events.CLIENT_DISCONNECTED, websocket=websocket)
Expand Down Expand Up @@ -421,6 +432,99 @@ 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(websocket, msg, msg_data)
elif kind == "resp":
# Response to a previously sent request
# Some implementations use "req_id", others use "id"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where did you see an id field in a response message?
Response messages may not contain an id field, but a req_id for the corresponding request message.

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
:param websocket: client connection
:param msg: event message name
: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] = {}

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),
)
await websocket.send(json.dumps(payload))

# Await response
resp = await asyncio.wait_for(fut, timeout=timeout)
return resp

except asyncio.TimeoutError as ex:
_LOG.error(
"[%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)
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.
Expand Down Expand Up @@ -687,6 +791,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,
Expand Down Expand Up @@ -929,7 +1034,9 @@ async def _entity_command(
entity.id,
)
result = await entity.command(
cmd_id, msg_data["params"] if "params" in msg_data else None
cmd_id,
msg_data["params"] if "params" in msg_data else None,
websocket=websocket,
)

await self.acknowledge_command(websocket, req_id, result)
Expand Down Expand Up @@ -1351,10 +1458,67 @@ 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]:
Comment thread
zehnm marked this conversation as resolved.
"""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,
resp.get("msg"),
)
return resp.get("msg_data", [])

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,
"get_version",
timeout=timeout,
)
if resp.get("msg") != "version":
_LOG.debug(
"[%s] Unexpected resp msg for get_version: %s",
websocket.remote_address,
resp.get("msg"),
)

return resp.get("msg_data")

async def get_localization_cfg(
self, websocket, *, timeout: float = 5.0
) -> dict[str, Any]:
"""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,
resp.get("msg"),
)

return resp.get("msg_data")

##############
# Properties #
##############

@property
def clients(self) -> set:
"""Return all clients."""
return self._clients.copy()

@property
def client_count(self) -> int:
"""Return number of WebSocket clients."""
Expand Down