Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 ec863f49ab Generate the PWA icons from the NestQuest logo (task 55e1e687)
generate-icons.mjs now decodes design/logos/NestQuest_Logo.png (pure
zlib, 8-bit RGB only, fails fast otherwise) and box-filter downscales it
to icon-192/icon-512; the maskable icon places the logo at 80% on the
logo's corner-sampled background colour. No new dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:17:57 -04:00
orchestrator-agent bf22aa8daa Merge main into dev after the 0.10.2 release 2026-10-06 13:11:32 -04:00
overseer f75969afe1 Merge pull request 'Release: panel SSE idle-timeout fix (0.10.2) (2026-10-06)' (#299) from release/2026-10-06-sse-timeout into main
Reviewed-on: #299
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 13:09:48 -04:00
orchestrator-agent b590cce9d9 release: advance version to 0.10.2 and add the 0.10.2 release notes 2026-10-06 09:14:53 -04:00
dev_agent 2939bede08 Disable the aiohttp total timeout on the panel SSE stream (task 6c4b0fa4) (#298)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 09:12:40 -04:00
orchestrator-agent 0ccf787dc6 Merge main into dev after the 0.10.1 release 2026-10-06 08:50:37 -04:00
12 changed files with 219 additions and 31 deletions
+28
View File
@@ -1,5 +1,33 @@
# NestQuest Release Notes
## Version 0.10.2 — 2026-10-06
### Scope
Patch release carrying the panel SSE idle-timeout fix onto `main` (PR #298, task
6c4b0fa4).
### Behaviour
- The panel event-stream request is no longer subject to aiohttp's default
~300 s total timeout; it now uses an unbounded total with a bounded connect,
so a healthy stream stays open instead of reconnecting every ~5 minutes.
Because the API does not replay transitions onto the Home Assistant event bus,
this also shrinks the windows in which a transition could be missed.
### Requirements
- Home Assistant floor 2024.6.0 (unchanged).
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Rollback
- No schema change: rolling back to 0.10.1 requires no database restore.
## Version 0.10.1 — 2026-10-06
### Scope
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+112 -22
View File
@@ -1,14 +1,12 @@
// Generates the committed PWA icons under public/icons/ as genuine PNGs.
// Generates the committed PWA icons under public/icons/ from the brand logo
// (design/logos/NestQuest_Logo.png), box-filter downscaled.
// Pure Node (zlib only) — no network, no image dependencies.
// Run: node scripts/generate-icons.mjs
import { writeFileSync } from "node:fs";
import { deflateSync } from "node:zlib";
import { readFileSync, writeFileSync } from "node:fs";
import { deflateSync, inflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
const BLUE = [0x3b, 0x3a, 0xb8];
const PURPLE = [0xa0, 0x35, 0xcc];
const WHITE = [0xff, 0xff, 0xff];
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
@@ -44,37 +42,129 @@ function encodePng(size, pixel) {
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
SIG,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
// safe zone (inner 80% circle).
function brandMark(size, scale) {
const c = size / 2;
const outer = size * 0.34 * scale;
const inner = size * 0.24 * scale;
const dot = size * 0.1 * scale;
// Minimal decoder for the source logo: 8-bit RGB, non-interlaced only.
// Anything else fails loudly rather than producing a wrong icon.
function decodePng(buf) {
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
let width, height;
const idat = [];
for (let off = 8; off < buf.length; ) {
const len = buf.readUInt32BE(off);
const type = buf.toString("ascii", off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
const [depth, colour, , , interlace] = data.subarray(8);
if (depth !== 8 || colour !== 2 || interlace !== 0) {
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
}
} else if (type === "IDAT") idat.push(data);
else if (type === "IEND") break;
off += 12 + len;
}
const raw = inflateSync(Buffer.concat(idat));
const stride = width * 3;
const px = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const src = y * (stride + 1) + 1;
const dst = y * stride;
for (let i = 0; i < stride; i++) {
const a = i >= 3 ? px[dst + i - 3] : 0;
const b = y > 0 ? px[dst - stride + i] : 0;
const c = i >= 3 && y > 0 ? px[dst - stride + i - 3] : 0;
let p;
if (filter === 0) p = 0;
else if (filter === 1) p = a;
else if (filter === 2) p = b;
else if (filter === 3) p = (a + b) >> 1;
else if (filter === 4) {
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
px[dst + i] = (raw[src + i] + p) & 0xff;
}
}
return { width, height, px };
}
// Box-filter (area-average) resize along one axis: each output sample is the
// coverage-weighted mean of the source samples it spans.
function boxWeights(srcLen, dstLen) {
const scale = srcLen / dstLen;
return Array.from({ length: dstLen }, (_, o) => {
const start = o * scale, end = start + scale;
const taps = [];
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
}
return taps;
});
}
function resize({ width, height, px }, size) {
const wx = boxWeights(width, size);
const wy = boxWeights(height, size);
const tmp = new Float64Array(size * height * 3); // horizontal pass
for (let y = 0; y < height; y++) {
for (let x = 0; x < size; x++) {
for (const [s, w] of wx[x]) {
for (let k = 0; k < 3; k++) tmp[(y * size + x) * 3 + k] += px[(y * width + s) * 3 + k] * w;
}
}
}
const out = new Uint8Array(size * size * 3); // vertical pass
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
for (let k = 0; k < 3; k++) {
let v = 0;
for (const [s, w] of wy[y]) v += tmp[(s * size + x) * 3 + k] * w;
out[(y * size + x) * 3 + k] = Math.min(255, Math.round(v));
}
}
}
return (x, y) => out.subarray((y * size + x) * 3, (y * size + x) * 3 + 3);
}
// Mean of the four corner pixels — the logo's solid background colour.
function cornerColour({ width, height, px }) {
const corners = [[0, 0], [width - 1, 0], [0, height - 1], [width - 1, height - 1]];
return [0, 1, 2].map((k) =>
Math.round(corners.reduce((sum, [x, y]) => sum + px[(y * width + x) * 3 + k], 0) / 4),
);
}
// `scale` < 1 shrinks the logo onto a background-coloured canvas so a
// maskable icon keeps the whole emblem inside the safe zone (inner 80% circle).
function logoIcon(logo, size, scale) {
if (scale === 1) return resize(logo, size);
const inner = Math.round(size * scale);
const offset = Math.floor((size - inner) / 2);
const pixel = resize(logo, inner);
const bg = cornerColour(logo);
return (x, y) => {
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
const t = (x + y) / (2 * (size - 1));
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
const ix = x - offset, iy = y - offset;
return ix >= 0 && iy >= 0 && ix < inner && iy < inner ? pixel(ix, iy) : bg;
};
}
const logo = decodePng(readFileSync(new URL("../../design/logos/NestQuest_Logo.png", import.meta.url)));
const outDir = new URL("../public/icons/", import.meta.url);
const icons = [
["icon-192.png", 192, 1],
["icon-512.png", 512, 1],
["icon-maskable-512.png", 512, 0.75],
["icon-maskable-512.png", 512, 0.8],
];
for (const [name, size, scale] of icons) {
const path = fileURLToPath(new URL(name, outDir));
writeFileSync(path, encodePng(size, brandMark(size, scale)));
writeFileSync(path, encodePng(size, logoIcon(logo, size, scale)));
console.log(`wrote ${path}`);
}
+22 -5
View File
@@ -16,9 +16,11 @@ network and NO aiohttp installed; production builds the client through
:func:`client_from_entry`, which hands in Home Assistant's shared
aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``). The module itself imports NEITHER
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
the HA coupling lives in the one production factory.
homeassistant NOR aiohttp at module import (aiohttp appears only
behind ``TYPE_CHECKING`` for annotations, plus one lazy import inside
:meth:`NestQuestApiClient.stream_events` for its ``ClientTimeout``), so
the client is importable anywhere — the HA coupling lives in the one
production factory.
Every failure is typed: a transport failure (connection refused, DNS
failure, timeout — the exchange never produced a response) and every
@@ -38,7 +40,11 @@ frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
line). Opening the stream gets the same one-timeout, typed-error
contract as every other call; reading it has NO body-wide timeout (a
healthy stream is long-lived — the subscription manager owns
reconnection and backoff).
reconnection and backoff). That includes the session's own default:
Home Assistant's shared session carries ``ClientTimeout(total=300)``,
which would cut a healthy stream every five minutes, so the stream
request overrides it with ``total=None`` (keeping a finite
``sock_connect``).
"""
from __future__ import annotations
@@ -278,8 +284,19 @@ class NestQuestApiClient:
(``aclose``, i.e. the consumer's task being cancelled) stops
cleanly and closes the underlying response in every path.
"""
# Lazy: aiohttp ships with Home Assistant, but importing it at
# module level would make the client unimportable without it.
import aiohttp
url = f"{self._base_url}{EVENTS_PATH}"
headers = self._headers()
# The session's default total timeout (HA's shared session:
# 300 s) would cut a healthy stream mid-body, and events fired
# while reconnecting are not replayed — so the stream request
# is unbounded in total time; only connecting stays bounded.
stream_timeout = aiohttp.ClientTimeout(
total=None, sock_connect=self._timeout_seconds
)
# Opening the response gets ONE request timeout, exactly like
# ``_request_json``; the read loop below runs WITHOUT one — a
# body-wide timeout would kill a healthy long-lived stream.
@@ -289,7 +306,7 @@ class NestQuestApiClient:
try:
async with asyncio.timeout(self._timeout_seconds):
stream_cm = self._session.request(
"GET", url, headers=headers, json=None
"GET", url, headers=headers, json=None, timeout=stream_timeout
)
response = await stream_cm.__aenter__()
except asyncio.CancelledError:
+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.1"
"version": "0.10.2"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.10.1"
version = "0.10.2"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
+23
View File
@@ -879,6 +879,29 @@ def set_coordinator_client(entry, client):
return client
@dataclass(frozen=True)
class FakeClientTimeout:
"""Stand-in for ``aiohttp.ClientTimeout`` (the fields the client sets)."""
total: float | None = None
sock_connect: float | None = None
@pytest.fixture
def fake_aiohttp(monkeypatch):
"""Install a stand-in ``aiohttp`` module for one test.
aiohttp ships only with Home Assistant, so this mock-only harness
does not have it; the API client imports it lazily inside
``stream_events`` (for ``ClientTimeout``). Tests driving the real
stream path request this fixture; the module is removed again when
the test ends.
"""
module = SimpleNamespace(ClientTimeout=FakeClientTimeout)
monkeypatch.setitem(sys.modules, "aiohttp", module)
return module
@pytest.fixture(autouse=True)
def coordinator_client_from_test_override():
"""Resolve every entry's coordinator client through the test seam.
+27 -1
View File
@@ -17,7 +17,7 @@ import types
import pytest
from conftest import make_config_entry
from conftest import FakeClientTimeout, make_config_entry
from custom_components.nestquest import api_client
from custom_components.nestquest.api_client import (
@@ -33,6 +33,10 @@ from custom_components.nestquest.const import (
DEFAULT_PANEL_TOKEN,
)
# ``stream_events`` imports aiohttp lazily; the harness has none, so
# every test here runs with the stand-in module installed.
pytestmark = pytest.mark.usefixtures("fake_aiohttp")
BASE_URL = "http://api.test:8000"
TOKEN = "test-panel-token"
@@ -124,6 +128,7 @@ class StubTransport:
"url": url,
"headers": kwargs.get("headers"),
"json": kwargs.get("json"),
"timeout": kwargs.get("timeout"),
}
)
if not self._outcomes:
@@ -557,3 +562,24 @@ async def test_stream_events_invalid_json_frame_raises_typed() -> None:
assert excinfo.value.status == 200
assert not excinfo.value.is_transport_error
assert isinstance(excinfo.value.__cause__, ValueError)
async def test_stream_events_disables_the_session_total_timeout() -> None:
"""The stream request overrides the session's default total timeout
(HA's shared session: 300 s, which would cut a healthy stream) with
``total=None`` while keeping a finite connect timeout; the plain
request calls pass no per-request timeout."""
transport = StubTransport(
StubStreamResponse(200, []), StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD))
)
client = make_client(transport, timeout_seconds=7.0)
frames = [frame async for frame in client.stream_events()]
await client.get_snapshot()
assert frames == []
stream_timeout = transport.calls[0]["timeout"]
assert isinstance(stream_timeout, FakeClientTimeout)
assert stream_timeout.total is None
assert stream_timeout.sock_connect == 7.0
assert transport.calls[1]["timeout"] is None
+4
View File
@@ -44,6 +44,10 @@ from custom_components.nestquest.api_client import (
)
from custom_components.nestquest.sse import NestQuestEventStream
# ``stream_events`` imports aiohttp lazily; the harness has none, so
# every test here runs with the stand-in module installed.
pytestmark = pytest.mark.usefixtures("fake_aiohttp")
BASE_URL = "http://api.test:8000"
TOKEN = "test-panel-token"
Generated
+1 -1
View File
@@ -373,7 +373,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.10.1"
version = "0.10.2"
source = { virtual = "." }
[package.dev-dependencies]