From d00f4ea173145457d6288f3b9962d37ad838e208 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 08:15:45 -0400 Subject: [PATCH 1/2] Read the panel SSE body from response.content (task 4cbf2e96) (#295) Co-authored-by: dev_agent --- custom_components/nestquest/api_client.py | 5 +- tests/test_api_client.py | 86 ++++++++++++++++++++++- tests/test_sse_subscription.py | 22 ++++-- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/custom_components/nestquest/api_client.py b/custom_components/nestquest/api_client.py index 9f3777f..5f30d44 100644 --- a/custom_components/nestquest/api_client.py +++ b/custom_components/nestquest/api_client.py @@ -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 diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 4978035..3082158 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -472,4 +472,88 @@ async def test_client_logs_nothing() -> None: await client.get_snapshot() finally: logging.getLogger().removeHandler(handler) - assert records == [] \ No newline at end of file + 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) diff --git a/tests/test_sse_subscription.py b/tests/test_sse_subscription.py index e0b0b62..a07069f 100644 --- a/tests/test_sse_subscription.py +++ b/tests/test_sse_subscription.py @@ -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() From b000579533832ee94018e1b2bae93881d4554845 Mon Sep 17 00:00:00 2001 From: orchestrator Date: Tue, 6 Oct 2026 08:20:26 -0400 Subject: [PATCH 2/2] release: advance version to 0.10.1 and add the 0.10.1 release notes --- RELEASE_NOTES.md | 37 +++++++++++++++++++++++ custom_components/nestquest/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 39f79ff..60e63ba 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index 2ab3c5f..537a1a0 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -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" } diff --git a/pyproject.toml b/pyproject.toml index 8baa0b5..9241836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/uv.lock b/uv.lock index d351eb0..b2e2d9c 100644 --- a/uv.lock +++ b/uv.lock @@ -373,7 +373,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.10.0" +version = "0.10.1" source = { virtual = "." } [package.dev-dependencies]