Skip to content

Comments

Websocket requests including supported entity types#47

Open
albaintor wants to merge 16 commits intounfoldedcircle:mainfrom
albaintor:websocket_requests
Open

Websocket requests including supported entity types#47
albaintor wants to merge 16 commits intounfoldedcircle:mainfrom
albaintor:websocket_requests

Conversation

@albaintor
Copy link

This is the PR I have modified from #46

@albaintor
Copy link
Author

albaintor commented Feb 1, 2026

All done and tested : #47

To test it and include it in the integrations :
python -m build on PR repository => this will generate an whl file in the dist folder
Just copy this whl in the source folder of the integration and edit requirements.txt with the right path like this :

ucapi
 file:./src/ucapi-0.5.4.dev3+geb80630e7.d20260201-py3-none-any.whl

This is what I have done for Kodi and it now works on Remote 2

Thanks @kennymc-c for the work, chatgpt helped me also for the rest :-)

@kennymc-c
Copy link

I'm now getting timeouts no matter how high I set the timeout value. Right after the timeout the response is shown in the log. Could something blocking the response?

@albaintor
Copy link
Author

I have fixed some stuff in the meantime : after authentication the integration is requesting supported entity types. Normally the remote should respond quickly to this request.

@albaintor
Copy link
Author

@zehnm could advise otherwise

@kennymc-c
Copy link

kennymc-c commented Feb 2, 2026

Also with the current version I still get a timeout

2026-02-02 14:11:19.083 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] ->: {'kind': 'req', 'id': 1, 'msg': 'get_supported_entity_types'}
2026-02-02 14:11:19.092 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] <-: {"kind":"req","id":3,"msg":"setup_driver","msg_data":{"reconfigure":false,"setup_data":{}}}
2026-02-02 14:11:19.092 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] ->: {'kind': 'resp', 'req_id': 3, 'code': 200, 'msg': 'result', 'msg_data': {}}
2026-02-02 14:11:19.092 | DEBUG    | config         | Stored setup_reconfigure: False into runtime storage
2026-02-02 14:11:19.092 | INFO     | setup          | Starting basic setup for a new device
2026-02-02 14:11:19.092 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] ->: {'kind': 'req', 'id': 2, 'msg': 'get_version'}
2026-02-02 14:11:24.089 | ERROR    | ucapi.api      | [('192.168.1.115', 37322)] Timeout waiting for response to get_supported_entity_types (req_id=1) 
2026-02-02 14:11:24.090 | ERROR    | ucapi.api      | [('192.168.1.115', 37322)] Unable to retrieve entity types 
2026-02-02 14:11:24.102 | ERROR    | ucapi.api      | [('192.168.1.115', 37322)] Timeout waiting for response to get_version (req_id=2) 
2026-02-02 14:11:24.102 | ERROR    | setup          | Could not retrieve remote information
2026-02-02 14:11:24.102 | DEBUG    | config         | Stored setup_step: basic into runtime storage
2026-02-02 14:11:24.103 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] ->: {'kind': 'event', 'msg': <WsMsgEvents.DRIVER_SETUP_CHANGE: 'driver_setup_change'>, 'msg_data': {'event_type': 'SETUP', 'state': 'SETUP'}, 'cat': <EventCategory.DEVICE: 'DEVICE'>}
2026-02-02 14:11:24.103 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] ->: {'kind': 'event', 'msg': <WsMsgEvents.DRIVER_SETUP_CHANGE: 'driver_setup_change'>, 'msg_data': {'event_type': 'SETUP', 'state': 'WAIT_USER_ACTION', 'require_user_action': {'input': {'title': {'en': 'Basic Setup', 'de': 'Allgemeine Einrichtung'}, 'settings': [{'id': 'notes', 'label': {'en': 'Basic Setup', 'de': 'Allgemeine Einrichtung'}, 'field': {'label': {'value': {'en': 'If you leave the ip field empty an attempt is made to **automatically find projectors**     via the SDAP advertisement service in your local network. \n\nData is only send **every 30 seconds by default**.     The search runs for 31 seconds to find all devices in your network.', 'de': 'Wenn du das Feld für die IP-Adresse leer lässt, wird versucht den Projektor per SDAP **automatisch     in deinem lokalen Netzwerk zu finden**\n\nDie Daten werden **standardmäßig nur alle 30 Sekunden** vom Projektor gesendet.     Die Suche läuft 31 Sekunden, um alle Geräte im Netzwerk zu finden.'}}}}, {'id': 'ip', 'label': {'en': 'Projector IP (leave empty to use auto discovery):', 'de': 'Projektor-IP (leer lassen zur automatischen Erkennung):'}, 'field': {'text': {'value': ''}}}, {'id': 'adcp_password', 'label': {'en': 'ADCP / WebUI password (only required if ADCP authentication is turned on):', 'de': 'ADCP / WebUI-Passwort (nur erforderlich bei aktivierter ADCP-Authentifizierung):'}, 'field': {'text': {'value': ''}}}, {'id': 'notes', 'label': {'en': 'Advanced settings', 'de': 'Erweiterte Einstellungen'}, 'field': {'label': {'value': {'en': 'If you have changed the default ADCP or SDAP ports, change timeouts     or the poller intervals you need to configure them in the advanced settings', 'de': 'Wenn du die ADCP oder SDAP Standard-Ports geändert hast, Timeouts oder     Poller-Intervalle ändern möchtest, musst du diese in den erweiterten Einstellungen konfigurieren'}}}}, {'id': 'advanced_settings', 'label': {'en': 'Configure advanced settings', 'de': 'Erweiterte Einstellungen konfigurieren'}, 'field': {'checkbox': {'value': False}}}]}}}, 'cat': <EventCategory.DEVICE: 'DEVICE'>}
2026-02-02 14:11:24.104 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] <-: {"id":4,"kind":"req","msg":"get_driver_version"}
2026-02-02 14:11:24.104 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] ->: {'kind': 'resp', 'req_id': 4, 'code': 200, 'msg': <WsMsgEvents.DRIVER_VERSION: 'driver_version'>, 'msg_data': {'name': 'Sony Projector (ADCP)', 'version': {'api': '0.25.0', 'driver': '1.4.2'}}}
2026-02-02 14:11:24.104 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] <-: {"kind":"resp","req_id":1,"msg":"supported_entity_types","code":200,"msg_data":["button","switch","climate","cover","light","media_player","sensor","activity","macro","remote","ir_emitter"]}
2026-02-02 14:11:24.104 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] WS: No pending map for resp_id=1 (late resp?)
2026-02-02 14:11:24.104 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] <-: {"kind":"resp","req_id":2,"msg":"version","code":200,"msg_data":{"address":"xxx","api":"0.13.0","core":"0.64.0-bt","device_name":"Remote Two","hostname":"RemoteTwo-dxxx.local","model":"UCR2","os":"2.7.0","ui":"0.62.2"}}
2026-02-02 14:11:24.104 | DEBUG    | ucapi.api      | [('192.168.1.115', 37322)] WS: No pending map for resp_id=2 (late resp?)

@albaintor
Copy link
Author

The problem comes from the remote core : it cannot accept any requests from the integration if it itself is waiting for a response from the integration after sending a request.
In your example :

  1. Remote -> integration : setup flow request
  2. Integration -> remote : get supported entity types => IGNORED by the remote
  3. integration -> remote : response to setup flow request
  4. NOW the remote can accept requests from the integration (and this is the case next in your example where it succeeds to retrieve entity types)

This is the reason why I chose to ignore the timeout in the ucapi and try again later, but the right way would be to accept requests at anytime on the core side. Don't know if this is easy to implement

@kennymc-c
Copy link

I'm back at my original non-blocking version that works fine in the same case. I also added a timeout to prevent a loop while waiting for an answer. As the timeout is 30 seconds just like the setup timeout I should get a possible exception before the setup itself times out.

@albaintor
Copy link
Author

Can you be more specific : in your version if I remember correctly the requests took the control over the websocket server until the response occurs ? In that case no others requests from the remote would be handled.

Anyway I don't understand how the result would differ from the new implementation : if you are in the setup flow and the remote expects a response from the integration, I don't get how you could send a request and get a response

@albaintor
Copy link
Author

Ok I have found the cause : the core is not in cause. The websocket handle of requests is blocked until a response is done.
I created tasks for each message to handle. Now we can make requests at anytime, including during the setup flow

@kennymc-c
Copy link

kennymc-c commented Feb 3, 2026

While it now works during the setup process I think I'm running into a race condition after a driver restart when adding the available entitites after receiving the connect event. The remote is asking for the available entitites but they have not yet all been added. Also the connect event is sent right after the setup again so I don't think it's the right place for this anway.
I used to add all available entities before the connect event but this doesn't seem to work anymore as I get a no active websocket connection error when using the request.

@albaintor
Copy link
Author

While it now works during the setup process I think I'm running into a race condition after a driver restart when adding the available entitites after receiving the connect event. The remote is asking for the available entitites but they have not yet all been added. Also the connect event is sent right after the setup again so I don't think it's the right place for this anway. I used to add all available entities before the connect event but this doesn't seem to work anymore as I get a no active websocket connection error when using the request.

This could be due to this code but I don't understand the problem : the supported entity types are extracted right after authentication but before the connected event so before the request for available entities. This should introduce just a request/response but no race condition... do you have logs ?

            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)

@kennymc-c
Copy link

According to my log the authentication respone comes after my request:

2026-02-03 21:19:48.468 | ERROR    | driver         | No active websocket connection!
2026-02-03 21:19:48.468 | ERROR    | driver         | Failed to get supported entity types from remote
2026-02-03 21:19:48.468 | WARNING  | driver         | Skip adding select entities as available entities
2026-02-03 21:19:48.873 | INFO     | ucapi.api      | WS: Client added: ('192.168.1.115', 43868)
2026-02-03 21:19:48.874 | DEBUG    | ucapi.api      | [('192.168.1.115', 43868)] ->: {'kind': 'resp', 'req_id': 0, 'code': 200, 'msg': <WsMessages.AUTHENTICATION: 'authentication'>, 'msg_data': {}}
2026-02-03 21:19:48.874 | DEBUG    | driver         | Remote websocket client connected to this integration websockets server
2026-02-03 21:19:48.874 | DEBUG    | driver         | There are currently 1 websocket clients connected to this integration websockets server
2026-02-03 21:19:48.875 | DEBUG    | ucapi.api      | [('192.168.1.115', 43868)] ->: {'kind': 'req', 'id': 1, 'msg': 'get_supported_entity_types'}
2026-02-03 21:19:48.889 | DEBUG    | ucapi.api      | [('192.168.1.115', 43868)] <-: {"kind":"resp","req_id":1,"msg":"supported_entity_types","code":200,"msg_data":["button","switch","climate","cover","light","media_player","sensor","activity","macro","remote","ir_emitter"]}
2026-02-03 21:19:48.889 | DEBUG    | ucapi.api      | [('192.168.1.115', 43868)] Supported entity types [<EntityTypes.BUTTON: 'button'>, <EntityTypes.SWITCH: 'switch'>, <EntityTypes.CLIMATE: 'climate'>, <EntityTypes.COVER: 'cover'>, <EntityTypes.LIGHT: 'light'>, <EntityTypes.MEDIA_PLAYER: 'media_player'>, <EntityTypes.SENSOR: 'sensor'>, <EntityTypes.REMOTE: 'remote'>, <EntityTypes.IR_EMITTER: 'ir_emitter'>]
2026-02-03 21:19:48.892 | DEBUG    | ucapi.api      | [('192.168.1.115', 43868)] <-: {"kind":"req","id":11,"msg":"subscribe_events","msg_data":{"entity_ids":["......
_LOG.debug("Starting driver")

await setup.init()

try:
    config.Setup.load()
    config.Devices.load()
except (OSError, Exception) as e:
    _LOG.critical(e)
    _LOG.critical("Stopping integration driver")
    raise SystemExit(0) from e

if config.Setup.get("setup_complete"):

	#Add media player, remote and sensor entities

    try:
        supported_entity_types = await api.get_supported_entity_types()
    except Exception as e:
        error_msg = str(e)
        if error_msg:
            _LOG.error(error_msg)
            _LOG.error("Failed to get supported entity types from remote")
            _LOG.warning("Skip adding select entities as available entities")
        else:
            _LOG.error("Failed to get supported entity types from remote")
            _LOG.warning("Skip adding select entities as available entities")
        supported_entity_types = []
    else:
        if ucapi.EntityTypes.SELECT in supported_entity_types:
            for select_type in config.Setup.get("select_types"):
                await selects.add(device_id, select_type)
        else:
            _LOG.warning("The currently installed remote firmware does not support select entities")
            _LOG.warning("Skip adding select entities as available entities")
            _LOG.info("Please update the remote firmware to version 2.8.3 or newer that supports select entities")

@albaintor
Copy link
Author

Normally the driver should be started before the setup flow like this

await api.init("driver.json", setup_flow.driver_setup_handler)

Anyway, I have moved the call to entity types extraction inside the request for available entities. The problem should not occur anymore hopefully

@zehnm zehnm self-requested a review February 4, 2026 12:51
@zehnm
Copy link
Contributor

zehnm commented Feb 4, 2026

Is this ready for review? Otherwise please mark this PR as draft if there are more updates or fixes coming.
I should be able to look at it towards the end of the week.

@albaintor albaintor marked this pull request as draft February 4, 2026 15:26
@albaintor
Copy link
Author

Is this ready for review? Otherwise please mark this PR as draft if there are more updates or fixes coming. I should be able to look at it towards the end of the week.

We thought it was but after testings it needed some adjustements. I will test the modifications before switching it back to review

@kennymc-c
Copy link

Thanks, now it works like expected without any addintional code that checks for entity types.
But maybe there should be a warning log message if entities are not added/ignored as users could think the entity has been added as there still is a [available] entity added: message shown for all entities although some entities are not actually added.

@albaintor
Copy link
Author

Same here, just tested on both my remote 2 and 3 : I misunderstood why the extraction of entity types didn't work. It was related to the blocking request on available entities. Now using tasks to handle websocket responses it works perfectly

@zehnm we are done now :)

@albaintor albaintor marked this pull request as ready for review February 4, 2026 19:28
Copy link
Contributor

@zehnm zehnm left a comment

Choose a reason for hiding this comment

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

Please explain the reason(s) of using locks and tasks to process received WebSocket messages.
I don't think they are required and complicate the message processing and might even introduce issues for integration driver usage. But I could also missed something :-)

ucapi/api.py Outdated
Comment on lines 469 to 489
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
"""
if websocket is None:
if not self._clients:
raise RuntimeError("No active websocket connection!")
websocket = next(iter(self._clients))
Copy link
Contributor

Choose a reason for hiding this comment

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

websocket parameter is not defined as optional. The doc implies it's required.

I think this should be a mandatory parameter. Using the first available connection if not provided is confusing.

Copy link
Author

Choose a reason for hiding this comment

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

I removed the optional parameter everywhere, however the question remains : how to call requests from integration driver ? A @Property clients should be added then to retrieve the list of websockets ?

ucapi/api.py Outdated
Comment on lines 517 to 518
async with self._ws_send_locks[websocket]:
await websocket.send(json.dumps(payload))
Copy link
Contributor

Choose a reason for hiding this comment

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

See my first comment about locks for self._req_id_lock = asyncio.Lock().

Even if locks are required, this code could raise a KeyError if the websocket disconnected and the lock was removed between the check on L494 and the usage here. --> every async call can stop the execution and the websocket might disconnect and go away, e.g. on L498.

Copy link
Author

Choose a reason for hiding this comment

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

Done

Comment on lines -221 to +238
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))
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are now tasks required to process the received messages?

Copy link
Author

Choose a reason for hiding this comment

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

This was the result of our tests, let's take the following example :

  1. We (integration) receive a request from the remote
  2. While processing the request, we need to send a request back to the remote and awaiting its response
  3. In that case the main task is blocked because it is waiting for the step 1 to return

Concrete example that I encountered :

  1. Remote -> Integration : get available entities
  2. Integration -> remote : get supported entity types
  3. remote -> integration : supported entity types
  4. integration -> remote : available entity types (filtered with supported entity types)

Without tasks, step 2 is blocked

Copy link
Contributor

Choose a reason for hiding this comment

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

It might be related to websocket's behaviour with asyncio, that there's a missing await asyncio.sleep(0) call: https://websockets.readthedocs.io/en/stable/faq/asyncio.html
But that's pure speculation right now, I'll try to reproduce this and also look into the asyncio task solution if that won't introduce other side effects.

Copy link
Author

Choose a reason for hiding this comment

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

I think that await asyncio.sleep(0) is kind of a hack to make the event loop look after other awaiting stuff.
Creating task to handle response seems to be the clean way to unlock the receive (main) task. But it may introduce some overhead I don't know : tasks in asyncio are not threads but this implies pushing data in the event loop.

Copy link
Contributor

Choose a reason for hiding this comment

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

This is no hack in an async context, depending what the client is doing. It's well documented in the websocket library, if the client is doing synchronous operations.

I have to dig deeper in the websocket library documentation about the async message callback. It's very well possible that the _handle_ws callback is awaited inside the websocket library, which means it should not be delayed for too long. Then it's clear why it doesn't work sending another WS message and waiting for the response inside the callback. The callback has to give control back, otherwise it blocks the event loop inside the websocket library. Additional await asyncio.sleep(0) won't solve it.
What I'm afraid of is that simply using an asyncio task for every received message might introduce other side effects. This is a major change in runtime behaviour.

@albaintor albaintor requested a review from zehnm February 12, 2026 21:00
@albaintor
Copy link
Author

I tested the latest changes including extraction of supported entity types

Copy link
Contributor

@zehnm zehnm left a comment

Choose a reason for hiding this comment

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

I can't approve creating asyncio tasks for every received WS message in the _handle_ws handler at the moment. It's simply too big of a runtime change, that requires a lot of time testing and making sure that no new side effects are introduced.
Processing a received message needs to yield back to the websocket library as fast as possible, as it was the case so far.
That means: no blocking calls in the message handler. If a received message has to trigger a lengthy operation, or has to wait for another WebSocket request, it has to be done in a separate task.

This PR does two things, both require proper solutions:

  1. Allowing an integration driver to send the defined requestes in the Integration-API, e.g. retrieven the localization configuration.
    Not yet solved: how does the integration driver get the websocket handle? See my comment with the additional event parameter.
  2. Filtering available entities, based on the supported entity types of the Remote device.
    Not yet solved: when to send the WS request to retrieve the supported types?
    I would approach this with an internal event handler listening for CLIENT_CONNECTED

One final request: please dont blindly trust what AI spits out. The I in today's AI is just intelligent marketing :-)

@@ -218,7 +216,6 @@ async def _handle_ws(self, websocket) -> None:
self._clients.add(websocket)
# Init per-websocket pending requests map + send lock
Copy link
Contributor

Choose a reason for hiding this comment

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

Cosmetic: "send lock" comment no longer correct

if websocket not in self._ws_send_locks:
self._ws_send_locks[websocket] = asyncio.Lock()

# Allocate req_id safely
Copy link
Contributor

Choose a reason for hiding this comment

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

Cosmetic: "safely" no longer relevant

Comment on lines +1278 to +1280
async def get_supported_entity_types(
self, websocket, *, timeout: float = 5.0
) -> list[str]:
Copy link
Contributor

Choose a reason for hiding this comment

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

From where does an integration driver get the websocket handle from? Same for the other new get_ methods below.
Accessing api._clients is not an option! Internal fields might change at any time.

An obvious option could be enhancing the emitted Events with a websocket parameter that a client could use if interested. That would even allow tracking multiple Remote connections in an external integration. Existing integrations would not be affected (as far as I understand the event emitting / Python parameter handling).
But I need to think about that a bit more.

Comment on lines -221 to +238
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))
)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is no hack in an async context, depending what the client is doing. It's well documented in the websocket library, if the client is doing synchronous operations.

I have to dig deeper in the websocket library documentation about the async message callback. It's very well possible that the _handle_ws callback is awaited inside the websocket library, which means it should not be delayed for too long. Then it's clear why it doesn't work sending another WS message and waiting for the response inside the callback. The callback has to give control back, otherwise it blocks the event loop inside the websocket library. Additional await asyncio.sleep(0) won't solve it.
What I'm afraid of is that simply using an asyncio task for every received message might introduce other side effects. This is a major change in runtime behaviour.

Comment on lines +806 to +814
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
]
Copy link
Contributor

Choose a reason for hiding this comment

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

This is the main issue: sending another WS message inside the message callback from the websocket library, and waiting for a response.

It also looks hackish: why request the supported entity types here?
If entity types need to be restricted, it should already be known at this time.
If there's no way around it, then this would be the place to create an asyncio task to not block the websocket event loops. It's a much safer approach than putting every received message into a task.

@zehnm
Copy link
Contributor

zehnm commented Feb 16, 2026

Regarding 1) with an additional websocket parameter for the Events: unfortunately Python for once isn't that flexible and complains at runtime if a parameter is added that is not defined in an event handler.

Goal: adding a websocket parameter, so an integration driver can request stuff from the Remote:

@api.listens_to(ucapi.Events.CONNECT)
async def on_connect(websocket) -> None:
    cfg = await api.get_localization_cfg(websocket)
    print(f"Got localization config: {cfg}")
    await api.set_device_state(ucapi.DeviceStates.CONNECTED)

This requires adding the websocket parameter to where the events are emitted. For example:

    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)
        elif msg == uc.WsMsgEvents.DISCONNECT:
            self._events.emit(uc.Events.DISCONNECT, websocket)
        elif msg == uc.WsMsgEvents.ENTER_STANDBY:
            self._events.emit(uc.Events.ENTER_STANDBY, websocket)
        elif msg == uc.WsMsgEvents.EXIT_STANDBY:
            self._events.emit(uc.Events.EXIT_STANDBY, websocket)
        # ...

Unfortunately this doesn't work with a typical CONNECT event handler in an integration driver:

@api.listens_to(ucapi.Events.CONNECT)
async def on_connect() -> None:
    await api.set_device_state(ucapi.DeviceStates.CONNECTED)

Runtime error: missing parameter

Proposed solution

We could either force a breaking change with a new 0.y minor library version and require all integrations to adapt. Even though this is valid with a 0-version, it's still not very developer friendly. It would also be a good idea to stop using position based arguments when emitting events.

By wrapping the event handler we can add backward compatiblity. Something like:

    @staticmethod
    def _wrap_event_listener(listener: Callable) -> Callable:
        """
        Wrap an event listener to enforce a kwargs-only event API while keeping
        backward compatibility for listeners that declare fewer (or zero) params.

        Contract:
          - The library MUST emit events using keyword arguments only.
          - Positional arguments are NOT supported for event delivery.
          - Unknown kwargs are dropped unless listener accepts **kwargs.
        """
        try:
            sig = inspect.signature(listener)
        except (TypeError, ValueError):
            # If we can't introspect, just call it and hope it supports **kwargs.
            return listener

        params = list(sig.parameters.values())
        accepts_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params)

        accepted_kw = {
            p.name
            for p in params
            if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
        }

        @wraps(listener)
        def wrapper(*args: Any, **kwargs: Any):
            # Enforce kwargs-only: never forward positional args to user code.
            # This prevents accidental signature breakage when adding new event params.
            if args:
                raise TypeError(
                    "Event listeners are called with keyword arguments only. "
                    "Library bug: positional args were provided."
                )

            if accepts_varkw:
                return listener(**kwargs)

            filtered = {k: v for k, v in kwargs.items() if k in accepted_kw}
            return listener(**filtered)

        return wrapper

    def add_listener(self, event: uc.Events, f: Callable) -> None:
        """
        Register a callback handler for the given event.

        :param event: the event
        :param f: callback handler
        """
        self._events.add_listener(event, self._wrap_event_listener(f))

    def listens_to(self, event: uc.Events) -> Callable[[Callable], Callable]:
        """
        Register the given event.

        :return: a decorator which will register the decorated function to the specified
                 event.
        """

        def on(f: Callable) -> Callable:
            self._events.add_listener(event, self._wrap_event_listener(f))
            return f

        return on

This wrapper allows both client event handler signatures:

@api.listens_to(ucapi.Events.CONNECT)
async def on_connect_old() -> None:
    await api.set_device_state(ucapi.DeviceStates.CONNECTED)

@api.listens_to(ucapi.Events.CONNECT)
async def on_connect_new(websocket) -> None:
    await api.set_device_state(ucapi.DeviceStates.CONNECTED)

By also documenting the Events enum, this should provide a usuable solution with minimal breakage.

class Events(str, Enum):
    """Internal library events."""

    CLIENT_CONNECTED = "client_connected"
    """WebSocket client connected.
    
    Parameters:
    
    - websocket: WebSocket client connection
    """
    CLIENT_DISCONNECTED = "client_disconnected"
    """WebSocket client disconnected.
    
    Parameters:
    
    - websocket: WebSocket client connection
    """
    ENTITY_ATTRIBUTES_UPDATED = "entity_attributes_updated"
    """Entity attributes updated.
    
    Parameters:
    
    - entity_id: entity identifier
    - entity_type: entity type
    - attributes: updated attributes
    """
    SUBSCRIBE_ENTITIES = "subscribe_entities"
    """Integration API `subscribe_events` message.

    Parameters:
    - entity_ids: list of entity IDs to subscribe to    
    - websocket: WebSocket client connection
    """
    UNSUBSCRIBE_ENTITIES = "unsubscribe_entities"
    """Integration API `unsubscribe_events` message.
    
    Parameters:
    - entity_ids: list of entity IDs to unsubscribe    
    - websocket: WebSocket client connection
    """
    CONNECT = "connect"
    """Integration-API `connect` event message.
    
    Parameters:
    
    - websocket: WebSocket client connection
    """
    DISCONNECT = "disconnect"
    """Integration-API `disconnect` event message.
    
    Parameters:
    
    - websocket: WebSocket client connection
    """
    ENTER_STANDBY = "enter_standby"
    """Integration-API `enter_standby` event message.
    
    Parameters:
    
    - websocket: WebSocket client connection
    """
    EXIT_STANDBY = "exit_standby"
    """Integration-API `exit_standby` event message.
    
    Parameters:
    
    - websocket: WebSocket client connection
    """

@albaintor unless you see a better solution I can implement this in a separate PR, which you then can merge from main.

@albaintor
Copy link
Author

Hi Markus, sounds good
Another thought that could be useful : when the remote requests for get_entity_states, the remote could also include the list of supported entity types. That would avoid a useless request from the driver for supported entity states

@zehnm
Copy link
Contributor

zehnm commented Feb 16, 2026

  1. Implemented in PR refactor: add named parameter support for events, provide websocket #49.
    Please review, otherwise I'll merge it tomorrow.
  2. Successfully tested with the following small refactoring:
  • Revert the asyncio.create_task change in _handle_ws
  • Move the request in a dedicated method and create a task for it:
        # ...
        elif msg == uc.WsMessages.GET_AVAILABLE_ENTITIES:
            asyncio.create_task(self._get_available_entities(websocket, req_id))
        # ...
    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},
        )

@zehnm
Copy link
Contributor

zehnm commented Feb 16, 2026

Another thought that could be useful : when the remote requests for get_entity_states, the remote could also include the list of supported entity types. That would avoid a useless request from the driver for supported entity states

That's an idea, but it should be for get_available_entities, not get_entity_states!
The first call is used to retrieve the available entities from the integration to present to the user. The second call is for updating the state of already configured entities.

@albaintor
Copy link
Author

yes get_available_entities :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants