Merge pull request 'Release: panel SSE stream fix (0.10.1) (2026-10-06)' (#296) from release/2026-10-06-sse-fix into main

Reviewed-on: #296
This commit was merged in pull request #296.
This commit is contained in:
2026-10-06 08:31:07 -04:00
7 changed files with 145 additions and 11 deletions
+37
View File
@@ -1,5 +1,42 @@
# NestQuest Release Notes
## Version 0.10.1 — 2026-10-06
### Scope
Patch release carrying the panel SSE stream fix onto `main` (PR #295, task
4cbf2e96).
### Behaviour
- The Home Assistant integration now reads the panel event stream from the
response body (`response.content`), fixing the logged failure
`'async for' requires an object with __aiter__ method, got HassClientResponse`
that logged ~935 times and prevented live panel events (completions,
un-completions, day-complete, missed) from re-firing from the API's SSE
stream. SSE line-buffering and JSON-frame parsing are unchanged.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Known limitations
- Intermittent `GET /api/v1/panel/events failed with HTTP 502` during API
restarts/redeploys is expected (the integration reconnects with backoff).
- The event stream still reconnects periodically because Home Assistant's
shared aiohttp session applies a default total timeout — tracked as a
follow-up; it does not affect correctness.
### Rollback
- No schema change: rolling back to 0.10.0 requires no database restore.
## Version 0.10.0 — 2026-09-30
### Scope
+4 -1
View File
@@ -315,7 +315,10 @@ class NestQuestApiClient:
event_type: str | None = None
data_lines: list[str] = []
buffer = ""
async for chunk in response:
# The body is ``response.content`` (aiohttp's StreamReader,
# yielding bytes); the response object itself — HA's
# ``HassClientResponse`` included — is NOT async-iterable.
async for chunk in response.content:
if isinstance(chunk, (bytes, bytearray)):
chunk = chunk.decode("utf-8")
buffer += chunk
+1 -1
View File
@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
"requirements": [],
"version": "0.10.0"
"version": "0.10.1"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.10.0"
version = "0.10.1"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+85 -1
View File
@@ -472,4 +472,88 @@ async def test_client_logs_nothing() -> None:
await client.get_snapshot()
finally:
logging.getLogger().removeHandler(handler)
assert records == []
assert records == []
# ---------------------------------------------------------------------------
# stream_events: the body is read from ``response.content``
# ---------------------------------------------------------------------------
class _StubStreamContent:
"""A fake aiohttp ``StreamReader``: async-iterable over byte chunks."""
def __init__(self, chunks) -> None:
self._chunks = list(chunks)
def __aiter__(self):
return self._iterate()
async def _iterate(self):
for chunk in self._chunks:
yield chunk
class StubStreamResponse:
"""A fake aiohttp/HA ``ClientResponse`` for a streaming body.
Like the real ``HassClientResponse`` it does NOT implement
``__aiter__`` — the body is iterated through ``.content`` — so a
client that iterates the response object itself raises the
``'async for' requires an object with __aiter__ method`` TypeError.
"""
def __init__(self, status: int, chunks) -> None:
self.status = status
self.content = _StubStreamContent(chunks)
async def test_stream_events_reads_frames_from_response_content() -> None:
"""Frames are parsed from ``response.content`` byte chunks, including
a frame split across chunk boundaries; a trailing incomplete frame
is discarded."""
assert not hasattr(StubStreamResponse, "__aiter__")
chunks = [
b'event: nestquest_quest_completed\ndata: {"instance_id"',
b': 7}\n',
b'\nevent: nestquest_child_day_complete\n',
b'data: {"child_id": 1}\n\n',
b'event: nestquest_quest_missed\ndata: {"instance_id": 9}\n',
]
transport = StubTransport(StubStreamResponse(200, chunks))
client = make_client(transport)
frames = [frame async for frame in client.stream_events()]
assert frames == [
("nestquest_quest_completed", {"instance_id": 7}),
("nestquest_child_day_complete", {"child_id": 1}),
]
assert transport.calls[0]["method"] == "GET"
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/events"
async def test_stream_events_non_2xx_raises_typed_with_status() -> None:
transport = StubTransport(StubStreamResponse(502, []))
client = make_client(transport)
with pytest.raises(NestQuestApiError) as excinfo:
async for _ in client.stream_events():
pass
assert excinfo.value.status == 502
assert not excinfo.value.is_transport_error
async def test_stream_events_invalid_json_frame_raises_typed() -> None:
transport = StubTransport(
StubStreamResponse(200, [b"event: nestquest_quest_completed\n", b"data: {nope\n\n"])
)
client = make_client(transport)
with pytest.raises(NestQuestApiError) as excinfo:
async for _ in client.stream_events():
pass
assert excinfo.value.status == 200
assert not excinfo.value.is_transport_error
assert isinstance(excinfo.value.__cause__, ValueError)
+16 -6
View File
@@ -165,19 +165,29 @@ MINIMAL_SNAPSHOT = {
class _StreamingResponse:
"""A fake aiohttp streaming response: ``status`` + chunk iteration.
"""A fake aiohttp streaming response: ``status`` + ``.content``.
``chunks`` are the body pieces the stream yields in order; a chunk
may be an exception, raised when the iteration reaches it (a
mid-stream transport drop). ``hold_open`` keeps the stream open
forever after the chunks run out, for cancellation tests.
Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
the response itself is NOT async-iterable; the body is iterated
through ``.content``. ``chunks`` are the body pieces the stream
yields in order; a chunk may be an exception, raised when the
iteration reaches it (a mid-stream transport drop). ``hold_open``
keeps the stream open forever after the chunks run out, for
cancellation tests.
"""
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
self.status = status
self.content = _StreamingContent(chunks, hold_open)
self.closed = False
class _StreamingContent:
"""The fake response body: an async-iterable chunk stream."""
def __init__(self, chunks, hold_open: bool) -> None:
self._chunks = list(chunks)
self._hold_open = hold_open
self.closed = False
def __aiter__(self):
return self._iterate()
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.10.0"
version = "0.10.1"
source = { virtual = "." }
[package.dev-dependencies]