Skip to content

Fix SSE field casing and add event handler support#46

Merged
OnFreund merged 19 commits into
masterfrom
sse-field-casing-and-event-handler
May 29, 2026
Merged

Fix SSE field casing and add event handler support#46
OnFreund merged 19 commits into
masterfrom
sse-field-casing-and-event-handler

Conversation

@OnFreund

@OnFreund OnFreund commented May 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fix SSE field casingisOfflineIsOffline, lastStatusUpdateLastStatusUpdate so offline guard and deduplication actually work
  • Push-based event log — SSE messages sometimes include LastEventUpdated; when present and newer, get_events is called and registered add_event_handler callbacks are notified (no more polling required)
  • Initial state on subscribe — after the SSE connection opens (but before consuming messages), a state fetch is performed so clients always start with current state and can never miss the window between login and first SSE message
  • LastEventUpdated independent of LastStatusUpdate — the two update paths are decoupled so a message carrying only LastEventUpdated still fires event handlers
  • Auto-reconnect with exponential backoff — on any connection error the loop retries with delays of 1 s, 2 s, 4 s, 8 s… (capped at 60 s); after RECONNECT_MAX_ATTEMPTS (5) consecutive connection failures the error handler is called with MaxRetriesError and the loop exits
  • MaxRetriesError — new exception exported from pyrisco; wraps the last underlying error so callers can distinguish "still retrying" from "gave up". Recommended response is to schedule a re-login via asyncio.create_task (not directly from the error handler, since close() awaits the subscription task and would deadlock if called from within it)
  • SSE response status checkresp.raise_for_status() on connect so 4xx/5xx responses go through the backoff/retry path instead of appearing as a clean EOF
  • Minimal delay on clean reconnect — 1 s delay after a server-side EOF prevents a tight reconnect loop
  • README + example updated — document backoff behaviour, MaxRetriesError handling pattern, add_event_handler, and remove the manual reconnect boilerplate

Test plan

  • python -m unittest tests/test_risco_cloud.py — all 18 tests pass
  • Deploy to a live HA instance and confirm SSE reconnects automatically after a network drop
  • Confirm MaxRetriesError is raised after 5 consecutive failed connection attempts

🤖 Generated with Claude Code

The Risco cloud SSE API sends IsOffline and LastStatusUpdate in PascalCase,
but the code was reading them as isOffline/lastStatusUpdate, causing the
offline check and deduplication logic to silently do nothing.

Also adds push-based event handling: when the SSE runtimeUpdate message
includes LastEventUpdated, get_events is called and registered event
handlers are invoked, so callers no longer need to poll for new events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the Risco Cloud SSE integration to correctly parse the API’s PascalCase runtimeUpdate payload fields and adds push-based event-log notifications when LastEventUpdated is present.

Changes:

  • Fix SSE payload field casing (IsOffline, LastStatusUpdate) so offline guarding and status deduplication work correctly.
  • Add event-log handler support via add_event_handler, tracking _last_event_update and fetching events when LastEventUpdated advances.
  • Update tests and the SSE example to reflect the real API casing and new event handler flow.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
pyrisco/cloud/risco_cloud.py Fixes SSE field casing; introduces event handler registration and LastEventUpdated-driven event fetching.
tests/test_risco_cloud.py Updates SSE test payload casing and adds tests covering the event-handler path.
examples/cloud/cloud_sse_example.py Demonstrates add_event_handler usage in the SSE example.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pyrisco/cloud/risco_cloud.py
Comment thread pyrisco/cloud/risco_cloud.py Outdated
OnFreund and others added 2 commits May 27, 2026 19:04
The API can return a null response body when the event log query returns
nothing. Guard against this by returning an empty list instead of crashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two issues from code review:
- _last_event_update is a datetime; convert to isoformat() before passing
  as newerThan to avoid JSON serialization error on the second fetch
- Skip get_events entirely when no event handlers are registered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

pyrisco/cloud/risco_cloud.py:179

  • Event updates are currently gated behind the LastStatusUpdate deduplication return. If a runtimeUpdate arrives with a non-newer LastStatusUpdate (or one that’s missing) but a newer LastEventUpdated, this function will return early and never fetch/notify event handlers. Consider handling LastEventUpdated independently of the state-update dedupe (e.g., evaluate event timestamp before the update_time <= _last_status_update early-return, and skip only the state fetch when status hasn’t changed).
    ts_str = data.get("LastStatusUpdate")
    if not ts_str:
      return
    update_time = datetime.fromisoformat(ts_str)
    if self._last_status_update is not None and update_time <= self._last_status_update:
      return

Comment thread pyrisco/cloud/risco_cloud.py Outdated
- Restructure _handle_runtime_update so LastEventUpdated is processed
  independently of LastStatusUpdate: a message with only LastEventUpdated
  (or a non-newer LastStatusUpdate) now correctly triggers event handlers
- Replace `if not response` with `if response is None` in get_events so
  an unexpected empty-dict response surfaces as a KeyError rather than
  being silently swallowed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread tests/test_risco_cloud.py Outdated
OnFreund and others added 2 commits May 27, 2026 19:50
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Before consuming any SSE messages, do one state fetch and notify
handlers. Since the SSE connection is already open at that point,
the server is buffering events — so no state changes in the window
between the fetch and the first message can be missed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread pyrisco/cloud/risco_cloud.py Outdated
… mismatch

Storing the datetime and calling .isoformat() produces +00:00, inconsistent
with the Z-suffixed strings the API uses. Store the original SSE string
instead and pass it directly to get_events; parse to datetime only locally
for comparison.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

OnFreund and others added 3 commits May 27, 2026 23:23
Network drops (e.g. ClientPayloadError mid-stream) are now handled
internally: the loop catches the exception, calls error handlers for
observability, sleeps RECONNECT_DELAY (30 s), then reconnects without
requiring any action from the caller.

The SSE example is updated to reflect that error handlers are now
informational-only and no longer need to drive reconnection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the flat 30s delay with exponential backoff: 1s, 2s, 4s, 8s…
capped at 60s. After RECONNECT_MAX_ATTEMPTS (5) consecutive failures the
loop gives up and calls error handlers one final time with MaxRetriesError
wrapping the last exception, then exits. A successful connection resets the
attempt counter so transient drops don't accumulate toward the limit.

MaxRetriesError is exported from the top-level pyrisco package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Show add_event_handler usage
- Remove manual reconnect from on_error (now handled automatically)
- Show MaxRetriesError handling pattern for when retries are exhausted
- Document exponential backoff and initial-state-on-subscribe behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread pyrisco/cloud/risco_cloud.py Outdated
Comment thread pyrisco/cloud/risco_cloud.py
close() now checks asyncio.current_task() before cancelling/awaiting
_subscription_task, so calling close() from within the subscription task
(e.g. via UnauthorizedError in _site_post) no longer deadlocks.

Add a RECONNECT_INITIAL_DELAY sleep after clean stream EOF before the
next connect attempt, preventing a tight reconnect loop when the server
cycles connections normally.

Mock asyncio.sleep at the TestRiscoCloud class level so reconnect delays
never slow the test suite, regardless of which code path is exercised.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@OnFreund OnFreund requested a review from Copilot May 28, 2026 03:42
- Add _parse_timestamp() helper that normalises trailing 'Z' to '+00:00'
  before calling fromisoformat(), making the code work on Python < 3.11
  and removing the need for callers to know about the format difference
- In README and example, schedule re-login via asyncio.create_task() so
  close() is called from a new task rather than from within the running
  SSE task — calling close() from within the task schedules CancelledError
  on itself, which would abort login() before it could complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread pyrisco/cloud/risco_cloud.py
Comment thread pyrisco/cloud/risco_cloud.py
- Call resp.raise_for_status() right after opening the SSE connection so
  4xx/5xx responses are treated as errors and go through the backoff/retry
  path rather than silently appearing as a clean EOF that resets attempt=0
- Revert close() to skip cancel() (not just await) when called from within
  the subscription task; calling cancel() on the current task schedules
  CancelledError at the next await, which can abort session.close() and
  other cleanup steps mid-flight. When close() is called from inside the
  task (e.g. UnauthorizedError recovery), the loop stops naturally because
  the session is closed beneath it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread pyrisco/cloud/risco_cloud.py
The real root cause of the close()-from-within-task problem was that
_site_post's UnauthorizedError recovery called close() + login() from
inside the SSE loop. close() cancels the subscription task, which means
any subsequent await (including session.close()) could receive
CancelledError and abort cleanup.

Fix: extract _relogin() which refreshes credentials in-place (clears
session_id, re-runs the three login steps) without touching the session
or the SSE task. _site_post now calls _relogin() instead of close()+login()
on UnauthorizedError.

close() is now simple and correct: always cancel+await, no current_task
check needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread pyrisco/cloud/risco_cloud.py
Comment thread README.md
The SSE stream drops for network reasons — the session token stays valid,
so UnauthorizedError never fires from within _sse_loop in practice. All
the _relogin() / close()-self-cancel complexity was solving a problem
that doesn't occur.

- Remove _relogin(); restore _site_post to close()+login() for the
  external arm/disarm case where 401 can legitimately happen
- Fix attempt reset: move it back to after clean EOF (not after
  raise_for_status). With the early reset, post-connect failures (e.g.
  initial state fetch) always saw attempt=0 and MaxRetriesError could
  never be reached
- Fix README and example comment: the real risk of calling close() from
  within the SSE error handler is a deadlock (close() awaits the running
  task itself), not that cancel() fires before login() completes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment thread pyrisco/cloud/risco_cloud.py Outdated
- Move attempt=0 to after initial state fetch succeeds (right before
  the message loop). Previously reset at clean EOF meant that if TCP
  connected but the state fetch always failed, attempt never accumulated
  past 1 and MaxRetriesError could never fire. Now reset requires the
  connection to reach a usable state (SSE up + initial state fetched).
- Add current_task() guard in close() so it always calls cancel() (to
  stop the loop) but skips await when called from within the subscription
  task itself, preventing a self-await deadlock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment thread pyrisco/cloud/risco_cloud.py
Comment thread pyrisco/cloud/risco_cloud.py Outdated
Comment thread pyrisco/cloud/risco_cloud.py Outdated
Add _call_handlers() static method that swallows per-handler exceptions
so a buggy or raising callback doesn't propagate into the SSE loop and
trigger a spurious reconnect/backoff. Pattern mirrors pyrisco/local's
_call_handlers. All state/event handler invocations now go through it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Comment thread pyrisco/cloud/risco_cloud.py Outdated
Comment thread pyrisco/cloud/risco_cloud.py Outdated
Comment thread pyrisco/cloud/risco_cloud.py Outdated
Comment thread pyrisco/cloud/risco_cloud.py Outdated
Comment thread pyrisco/cloud/risco_cloud.py Outdated
Comment thread pyrisco/cloud/risco_cloud.py Outdated
Switch _call_handlers to the same non-blocking dispatch as
pyrisco/local/risco_local.py: a synchronous method that schedules
asyncio.create_task(_gather()) so handler calls don't block the SSE
reader. return_exceptions=True on gather means handler exceptions are
captured, not propagated.

Apply to error handlers too for consistency — a raising error handler
no longer risks aborting the reconnect sleep or MaxRetriesError return.

Tests drain pending handler tasks after the subscription task ends via
asyncio.gather(*all_tasks) so assertions remain deterministic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@OnFreund OnFreund merged commit 71a3701 into master May 29, 2026
2 checks passed
@OnFreund OnFreund deleted the sse-field-casing-and-event-handler branch May 29, 2026 20:42
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.

2 participants