Compare commits

...
Author SHA1 Message Date
overseer 9eb5b34068 Merge pull request 'Release: integration brand icons (0.10.4) (2026-10-06)' (#305) from release/2026-10-06-brand-icons into main
Reviewed-on: #305
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 17:19:13 -04:00
orchestrator-agent 9daf27fb8e release: advance version to 0.10.4 and add the 0.10.4 release notes 2026-10-06 17:05:00 -04:00
dev_agent da375a2cd7 Add the NestQuest logo to the README and prepare HACS brand icons (task ac84fa96) (#304)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 17:02:24 -04:00
orchestrator-agent 9cec30b88a Merge main into dev after the 0.10.3 release 2026-10-06 13:53:13 -04:00
overseer ab3859a6cf Merge pull request 'Release: admin PWA desktop icon (0.10.3) (2026-10-06)' (#302) from release/2026-10-06-pwa-icon into main
Reviewed-on: #302
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 13:46:56 -04:00
orchestrator-agent b7e5f1fada release: advance version to 0.10.3 and add the 0.10.3 release notes 2026-10-06 13:41:08 -04:00
dev_agent 16fb466480 Generate the PWA icons from the NestQuest logo (task 55e1e687) (#301)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 13:37:41 -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
overseer e033ad2ad5 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
2026-10-06 08:31:07 -04:00
20 changed files with 567 additions and 31 deletions
+4
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="design/logos/NestQuest-NoBG.png" alt="NestQuest logo" width="200">
</p>
# NestQuest - House Chaos Coordination
Home Assistant custom integration for managing chores, custody schedules, and house coordination.
+82
View File
@@ -1,5 +1,87 @@
# NestQuest Release Notes
## Version 0.10.4 — 2026-10-06
### Scope
Patch release carrying the integration brand icons onto `main` (PR #304, task
ac84fa96).
### Behaviour
- `custom_components/nestquest/brand/` now ships the transparent NestQuest logo
(`icon.png`/`icon@2x.png`/`logo.png`/`logo@2x.png`), so Home Assistant 2026.3+
shows it on the integrations page and tile after a HACS update.
- `README.md` embeds the logo at the top (shown on the HACS repository page).
### Requirements
- Home Assistant floor 2024.6.0 (unchanged). The brand-image UI itself requires
Home Assistant 2026.3+; older versions show the generic icon.
### Breaking changes
- None. Schema unchanged at version 9; no migration, no database backup
required.
### Rollback
- No schema change: rolling back to 0.10.3 requires no database restore.
## Version 0.10.3 — 2026-10-06
### Scope
Patch release carrying the admin PWA desktop-icon update onto `main` (PR #301,
task 55e1e687).
### Behaviour
- The admin PWA's installable icons (192, 512, and 512 maskable) are now
generated from `design/logos/NestQuest_Logo.png` instead of placeholder
graphics, so a home-screen install of the admin app shows the NestQuest logo.
### 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.2 requires no database restore.
## 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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+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.4"
}
+48
View File
@@ -0,0 +1,48 @@
# NestQuest brand icons in Home Assistant
## Where the icon comes from
Since Home Assistant 2026.3, a custom integration can ship its own brand
images in `custom_components/<domain>/brand/`, and Home Assistant serves them
ahead of the brands CDN (`brands.home-assistant.io`). NestQuest ships:
| File | Size |
|------|------|
| `custom_components/nestquest/brand/icon.png` | 256×256 |
| `custom_components/nestquest/brand/icon@2x.png` | 512×512 |
| `custom_components/nestquest/brand/logo.png` | 256×256 |
| `custom_components/nestquest/brand/logo@2x.png` | 512×512 |
HACS installs the whole `custom_components/nestquest/` directory, so these
images arrive with the integration. On Home Assistant 2026.3 or newer they
appear on the integrations page and the integration tile, through Home
Assistant's brand endpoint. Older Home Assistant versions ignore the directory
and show the generic placeholder icon.
The `custom_integrations` folder of the `home-assistant/brands` repository is
legacy for this purpose; NestQuest does not depend on it.
## Known caveat: the HACS store card
The HACS store card (the repository listing inside HACS, shown before and
after installing) is rendered by HACS itself, not by the integration. It may
still take its image from the brands CDN or fall back to the avatar of the
repository owner (the `Maniacs-Mansion` organization). Whether HACS reads the
local `brand/` directory for that card has not been verified, so that icon
may stay generic. The repository page inside HACS shows the README, which
embeds the logo from `design/logos/NestQuest-NoBG.png`.
## Regenerating
The PNGs are generated offline and deterministically (pure Node, zlib only)
from `design/logos/NestQuest-NoBG.png`, box-filter downscaled with
transparency kept. From the repository root:
```bash
node design/logos/generate-brand-icons.mjs # write the four PNGs
node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
```
`tests/test_brand_icons.py` runs the `--check` and asserts the four files
exist with the expected sizes, so a missing or hand-edited image fails the
test suite.
+187
View File
@@ -0,0 +1,187 @@
// Generates the integration's local brand images in
// custom_components/nestquest/brand/ (read by Home Assistant 2026.3+) from the
// transparent logo (design/logos/NestQuest-NoBG.png), box-filter downscaled
// with alpha kept. Adapted from admin/scripts/generate-icons.mjs for RGBA.
// Pure Node (zlib only) — no network, no image dependencies.
// Run (from the repository root):
// node design/logos/generate-brand-icons.mjs # write the PNGs
// node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { deflateSync, inflateSync } from "node:zlib";
import { fileURLToPath } from "node:url";
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function encodePng(size, rgba) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type: RGBA
const stride = size * 4;
const raw = Buffer.alloc(size * (stride + 1));
for (let y = 0; y < size; y++) {
raw[y * (stride + 1)] = 0; // filter: none
raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
return Buffer.concat([
SIG,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// Minimal decoder for the source logo: 8-bit RGBA, 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 !== 6 || 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 * 4;
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 >= 4 ? px[dst + i - 4] : 0;
const b = y > 0 ? px[dst - stride + i] : 0;
const c = i >= 4 && y > 0 ? px[dst - stride + i - 4] : 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;
}
}
if (width !== height) throw new Error(`logo must be square, got ${width}x${height}`);
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;
});
}
// Colour is averaged premultiplied by alpha, so fully transparent source
// pixels (whatever RGB they carry) cannot bleed a fringe into the edges.
function resize({ width, height, px }, size) {
const wx = boxWeights(width, size);
const wy = boxWeights(height, size);
const tmp = new Float64Array(size * height * 4); // horizontal pass, premultiplied
for (let y = 0; y < height; y++) {
for (let x = 0; x < size; x++) {
const t = (y * size + x) * 4;
for (const [s, w] of wx[x]) {
const p = (y * width + s) * 4;
const aw = px[p + 3] * w;
for (let k = 0; k < 3; k++) tmp[t + k] += px[p + k] * aw;
tmp[t + 3] += aw;
}
}
}
const out = new Uint8Array(size * size * 4); // vertical pass, un-premultiply
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const acc = [0, 0, 0, 0];
for (const [s, w] of wy[y]) {
const t = (s * size + x) * 4;
for (let k = 0; k < 4; k++) acc[k] += tmp[t + k] * w;
}
const o = (y * size + x) * 4;
const alpha = Math.min(255, Math.round(acc[3]));
for (let k = 0; k < 3; k++) {
out[o + k] = alpha === 0 ? 0 : Math.min(255, Math.round(acc[k] / acc[3]));
}
out[o + 3] = alpha;
}
}
return out;
}
const logo = decodePng(readFileSync(new URL("NestQuest-NoBG.png", import.meta.url)));
const outDir = new URL("../../custom_components/nestquest/brand/", import.meta.url);
const files = [
["icon.png", 256],
["icon@2x.png", 512],
["logo.png", 256],
["logo@2x.png", 512],
];
const check = process.argv.includes("--check");
let drifted = false;
if (!check) mkdirSync(outDir, { recursive: true });
for (const [name, size] of files) {
const path = fileURLToPath(new URL(name, outDir));
const png = encodePng(size, resize(logo, size));
if (check) {
let current = null;
try {
current = readFileSync(path);
} catch {
// missing counts as drift
}
if (!current || !current.equals(png)) {
drifted = true;
console.error(`drift: ${path} differs from a fresh generation`);
}
} else {
writeFileSync(path, png);
console.log(`wrote ${path}`);
}
}
if (drifted) {
console.error("run `node design/logos/generate-brand-icons.mjs` and commit the result");
process.exit(1);
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.10.1"
version = "0.10.4"
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
+55
View File
@@ -0,0 +1,55 @@
"""The integration's local brand images (Home Assistant 2026.3+).
``custom_components/nestquest/brand/`` ships through HACS with the
integration, and Home Assistant serves those images ahead of the brands CDN.
They are generated offline from ``design/logos/NestQuest-NoBG.png`` by
``design/logos/generate-brand-icons.mjs``; these checks fail if the directory
goes missing, changes shape, or drifts from a fresh generation.
"""
from __future__ import annotations
import shutil
import struct
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
BRAND_DIR = REPO_ROOT / "custom_components" / "nestquest" / "brand"
GENERATOR = REPO_ROOT / "design" / "logos" / "generate-brand-icons.mjs"
EXPECTED = {
"icon.png": 256,
"icon@2x.png": 512,
"logo.png": 256,
"logo@2x.png": 512,
}
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def test_brand_dir_holds_exactly_the_four_images() -> None:
assert sorted(p.name for p in BRAND_DIR.iterdir()) == sorted(EXPECTED)
@pytest.mark.parametrize(("name", "size"), sorted(EXPECTED.items()))
def test_brand_image_is_square_rgba_png(name: str, size: int) -> None:
data = (BRAND_DIR / name).read_bytes()
assert data[:8] == PNG_SIGNATURE
assert data[12:16] == b"IHDR"
width, height, depth, colour = struct.unpack(">IIBB", data[16:26])
assert (width, height) == (size, size)
assert (depth, colour) == (8, 6) # 8-bit RGBA keeps the transparency
def test_generator_reproduces_the_committed_images() -> None:
node = shutil.which("node")
if node is None:
pytest.skip("needs node")
result = subprocess.run(
[node, str(GENERATOR), "--check"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
+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.4"
source = { virtual = "." }
[package.dev-dependencies]