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:
@@ -1,5 +1,42 @@
|
|||||||
# NestQuest Release Notes
|
# 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
|
## Version 0.10.0 — 2026-09-30
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|||||||
@@ -315,7 +315,10 @@ class NestQuestApiClient:
|
|||||||
event_type: str | None = None
|
event_type: str | None = None
|
||||||
data_lines: list[str] = []
|
data_lines: list[str] = []
|
||||||
buffer = ""
|
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)):
|
if isinstance(chunk, (bytes, bytearray)):
|
||||||
chunk = chunk.decode("utf-8")
|
chunk = chunk.decode("utf-8")
|
||||||
buffer += chunk
|
buffer += chunk
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "0.10.0"
|
"version": "0.10.1"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nestquest"
|
name = "nestquest"
|
||||||
version = "0.10.0"
|
version = "0.10.1"
|
||||||
description = "NestQuest Home Assistant Integration"
|
description = "NestQuest Home Assistant Integration"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -472,4 +472,88 @@ async def test_client_logs_nothing() -> None:
|
|||||||
await client.get_snapshot()
|
await client.get_snapshot()
|
||||||
finally:
|
finally:
|
||||||
logging.getLogger().removeHandler(handler)
|
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)
|
||||||
|
|||||||
@@ -165,19 +165,29 @@ MINIMAL_SNAPSHOT = {
|
|||||||
|
|
||||||
|
|
||||||
class _StreamingResponse:
|
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
|
Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
|
||||||
may be an exception, raised when the iteration reaches it (a
|
the response itself is NOT async-iterable; the body is iterated
|
||||||
mid-stream transport drop). ``hold_open`` keeps the stream open
|
through ``.content``. ``chunks`` are the body pieces the stream
|
||||||
forever after the chunks run out, for cancellation tests.
|
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:
|
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
|
||||||
self.status = status
|
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._chunks = list(chunks)
|
||||||
self._hold_open = hold_open
|
self._hold_open = hold_open
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
def __aiter__(self):
|
def __aiter__(self):
|
||||||
return self._iterate()
|
return self._iterate()
|
||||||
|
|||||||
Reference in New Issue
Block a user