fix(solis): queue entity events for the component loop - #4875
Conversation
Solis entity callbacks perform real API reads and writes inline. They are invoked from the HA component's loop (ha.py -> trigger_callback), not Solis's own, because standalone Predbat runs every component in its own thread with its own asyncio.run() loop (hass.py). The ClientSession belongs to the Solis loop, so issuing a request from the callback loop raises inside aiohttp; the handler swallows it and returns normally, and the user is told a write succeeded that never reached the inverter. Ohme and Octopus already avoid this: their callbacks only append to a queue and the work happens in their own run(). Solis now does the same. select_event, number_event and switch_event become thin stubs that queue, the existing bodies become *_event_handler, and run() drains the queue on the Solis loop before polling. A failing handler is logged and the rest of the queue still drains, since the queue is in memory only and anything dropped is lost outright. This keeps one session on one loop, so connection reuse, socket lifetime and teardown are all unchanged — no per-loop session juggling, and nothing special for injected test sessions. Existing event tests now call the handlers directly, which is what they were always exercising. Two new tests cover the dispatch itself: that a callback queues rather than executing on the calling loop, and that one failing event does not strand the rest. MockSolisAPI gains queued_events, as it hand-rolls the state the real __init__ would set. Verified with the repository harness (unit_test.py --test solis, which --quick skips): the suite passes, and the two pre-existing unclosed-session warnings and the multi_car_iog failure are present on a clean tree too. Mutation-checked — making select_event call its handler inline again fails the new test.
There was a problem hiding this comment.
🟡 Changes recommended
run() currently drains queued events before first-cycle session creation/inverter discovery, which can drop or mis-handle early queued writes during startup.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR changes the Solis component so Home Assistant entity callbacks (select_event, number_event, switch_event) no longer perform API I/O inline on the HA loop, and instead queue work for the Solis component loop to execute (avoiding cross-event-loop aiohttp.ClientSession usage).
Changes:
- Add an in-memory
queued_eventsqueue to Solis, convert entity callbacks into stubs that enqueue handler calls, and drain the queue fromrun()on the Solis loop. - Rename the previous callback bodies into
*_event_handlermethods and update Solis tests to call handlers directly. - Add tests asserting callbacks queue (don’t execute on the calling loop) and that one failing queued event doesn’t block subsequent events.
File summaries
| File | Description |
|---|---|
| apps/predbat/solis.py | Introduces queued event stubs + handler methods and drains queued events in run() to keep API work on the Solis loop. |
| apps/predbat/tests/test_solis.py | Updates existing tests to target handlers and adds coverage for queuing/dispatch behavior. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot review: run() drained the event queue before the startup block created the ClientSession and discovered inverters, so an event queued during startup ran against session=None and an empty inverter list and was popped and lost. Drain after the startup block instead; on a failed startup the queue is left intact for the next attempt. Adds a test asserting an event queued before the first run() drains only after discovery (mutation-checked). Co-Authored-By: Claude Code <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The current queue-draining loop can starve polling indefinitely if callbacks append events while draining, which is a reliability risk and can be fixed with a small, local change.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
| while self.queued_events: | ||
| handler, *args = self.queued_events.pop(0) | ||
| try: | ||
| await handler(*args) | ||
| except Exception as e: | ||
| self.log("Warn: Solis API: Event handler error: {}".format(e)) |
Problem
Solis entity callbacks perform real API reads and writes inline —
solis.py:2650,:2880,:3056.But those callbacks run on the HA component's event loop, not Solis's own. Standalone Predbat runs every component in its own thread with its own
asyncio.run()loop (hass.py:217,:223), and the HA interface is one of those components — its websocket loop awaitstrigger_callback()directly (ha.py:619).The
ClientSessionis bound to the Solis loop. Issuing a request from the callback loop raises inside aiohttp, the handler catches it and returns normally, and the user is told a write succeeded that never reached the inverter.Why Solis specifically
Ohme and Octopus already avoid this — their callbacks only append to a queue and do the work in their own
run():octopus.py:591), executed inrun()(:641)ohme.py:689), drained inrun()(:239)Change
Solis follows the same pattern.
select_event,number_eventandswitch_eventbecome thin stubs that queue; the existing bodies become*_event_handler;run()drains the queue on the Solis loop before polling. A failing handler is logged and the rest of the queue still drains — the queue is in memory only, so anything dropped is lost outright.This deliberately does not touch session lifetime. One session stays on one loop, so connection reuse, socket lifetime and teardown are all unchanged, and injected test sessions keep working.
Tests
Existing event tests now call the handlers directly — that is what they were always exercising.
Two new tests cover the dispatch itself: that a callback queues rather than executing on the calling loop, and that one failing event does not strand the rest of the queue.
MockSolisAPIgainsqueued_events, since it hand-rolls the state the real__init__sets.Verified with the repository harness —
unit_test.py --test solis, which--quickskips:Unclosed client sessionwarnings and themulti_car_iogfailure are present on a clean tree too, so neither is introduced here.select_eventcall its handler inline again fails the new test withcallback executed API work on the calling loop.Note
This supersedes #4874, which tried to solve it at the session layer by rebinding the
ClientSessionper loop. That was the wrong level — it broke injected test sessions, leaked a session per rebind, and destroyed connection reuse in exactly the alternating-loop case it targeted. Closing that in favour of this.