Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb5b34068 | ||
|
|
9daf27fb8e | ||
|
|
da375a2cd7 | ||
|
|
9cec30b88a | ||
|
|
ab3859a6cf | ||
|
|
b7e5f1fada | ||
|
|
16fb466480 | ||
|
|
bf22aa8daa | ||
|
|
f75969afe1 | ||
|
|
b590cce9d9 | ||
|
|
2939bede08 | ||
|
|
0ccf787dc6 | ||
|
|
e033ad2ad5 |
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="design/logos/NestQuest-NoBG.png" alt="NestQuest logo" width="200">
|
||||||
|
</p>
|
||||||
|
|
||||||
# NestQuest - House Chaos Coordination
|
# NestQuest - House Chaos Coordination
|
||||||
|
|
||||||
Home Assistant custom integration for managing chores, custody schedules, and house coordination.
|
Home Assistant custom integration for managing chores, custody schedules, and house coordination.
|
||||||
|
|||||||
@@ -1,5 +1,87 @@
|
|||||||
# NestQuest Release Notes
|
# 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
|
## Version 0.10.1 — 2026-10-06
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 178 KiB |
@@ -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.
|
// Pure Node (zlib only) — no network, no image dependencies.
|
||||||
// Run: node scripts/generate-icons.mjs
|
// Run: node scripts/generate-icons.mjs
|
||||||
import { writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
import { deflateSync } from "node:zlib";
|
import { deflateSync, inflateSync } from "node:zlib";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// Brand tokens (design/tokens/nestquest-admin-tokens.css).
|
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
const BLUE = [0x3b, 0x3a, 0xb8];
|
|
||||||
const PURPLE = [0xa0, 0x35, 0xcc];
|
|
||||||
const WHITE = [0xff, 0xff, 0xff];
|
|
||||||
|
|
||||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||||
let c = 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);
|
for (let x = 0; x < size; x++) raw.set(pixel(x, y), row + 1 + x * 3);
|
||||||
}
|
}
|
||||||
return Buffer.concat([
|
return Buffer.concat([
|
||||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
SIG,
|
||||||
chunk("IHDR", ihdr),
|
chunk("IHDR", ihdr),
|
||||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||||
chunk("IEND", Buffer.alloc(0)),
|
chunk("IEND", Buffer.alloc(0)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flat brand mark: diagonal blue→purple gradient with a white ring ("nest")
|
// Minimal decoder for the source logo: 8-bit RGB, non-interlaced only.
|
||||||
// around a white dot ("quest"). `scale` shrinks the mark for the maskable
|
// Anything else fails loudly rather than producing a wrong icon.
|
||||||
// safe zone (inner 80% circle).
|
function decodePng(buf) {
|
||||||
function brandMark(size, scale) {
|
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
|
||||||
const c = size / 2;
|
let width, height;
|
||||||
const outer = size * 0.34 * scale;
|
const idat = [];
|
||||||
const inner = size * 0.24 * scale;
|
for (let off = 8; off < buf.length; ) {
|
||||||
const dot = size * 0.1 * scale;
|
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) => {
|
return (x, y) => {
|
||||||
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
|
const ix = x - offset, iy = y - offset;
|
||||||
if ((d <= outer && d >= inner) || d <= dot) return WHITE;
|
return ix >= 0 && iy >= 0 && ix < inner && iy < inner ? pixel(ix, iy) : bg;
|
||||||
const t = (x + y) / (2 * (size - 1));
|
|
||||||
return BLUE.map((b, i) => Math.round(b + (PURPLE[i] - b) * t));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logo = decodePng(readFileSync(new URL("../../design/logos/NestQuest_Logo.png", import.meta.url)));
|
||||||
const outDir = new URL("../public/icons/", import.meta.url);
|
const outDir = new URL("../public/icons/", import.meta.url);
|
||||||
const icons = [
|
const icons = [
|
||||||
["icon-192.png", 192, 1],
|
["icon-192.png", 192, 1],
|
||||||
["icon-512.png", 512, 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) {
|
for (const [name, size, scale] of icons) {
|
||||||
const path = fileURLToPath(new URL(name, outDir));
|
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}`);
|
console.log(`wrote ${path}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
:func:`client_from_entry`, which hands in Home Assistant's shared
|
||||||
aiohttp session (``homeassistant.helpers.aiohttp_client
|
aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||||
.async_get_clientsession``). The module itself imports NEITHER
|
.async_get_clientsession``). The module itself imports NEITHER
|
||||||
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
|
homeassistant NOR aiohttp at module import (aiohttp appears only
|
||||||
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
|
behind ``TYPE_CHECKING`` for annotations, plus one lazy import inside
|
||||||
the HA coupling lives in the one production factory.
|
: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
|
Every failure is typed: a transport failure (connection refused, DNS
|
||||||
failure, timeout — the exchange never produced a response) and every
|
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
|
line). Opening the stream gets the same one-timeout, typed-error
|
||||||
contract as every other call; reading it has NO body-wide timeout (a
|
contract as every other call; reading it has NO body-wide timeout (a
|
||||||
healthy stream is long-lived — the subscription manager owns
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -278,8 +284,19 @@ class NestQuestApiClient:
|
|||||||
(``aclose``, i.e. the consumer's task being cancelled) stops
|
(``aclose``, i.e. the consumer's task being cancelled) stops
|
||||||
cleanly and closes the underlying response in every path.
|
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}"
|
url = f"{self._base_url}{EVENTS_PATH}"
|
||||||
headers = self._headers()
|
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
|
# Opening the response gets ONE request timeout, exactly like
|
||||||
# ``_request_json``; the read loop below runs WITHOUT one — a
|
# ``_request_json``; the read loop below runs WITHOUT one — a
|
||||||
# body-wide timeout would kill a healthy long-lived stream.
|
# body-wide timeout would kill a healthy long-lived stream.
|
||||||
@@ -289,7 +306,7 @@ class NestQuestApiClient:
|
|||||||
try:
|
try:
|
||||||
async with asyncio.timeout(self._timeout_seconds):
|
async with asyncio.timeout(self._timeout_seconds):
|
||||||
stream_cm = self._session.request(
|
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__()
|
response = await stream_cm.__aenter__()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -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.1"
|
"version": "0.10.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nestquest"
|
name = "nestquest"
|
||||||
version = "0.10.1"
|
version = "0.10.4"
|
||||||
description = "NestQuest Home Assistant Integration"
|
description = "NestQuest Home Assistant Integration"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -879,6 +879,29 @@ def set_coordinator_client(entry, client):
|
|||||||
return 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)
|
@pytest.fixture(autouse=True)
|
||||||
def coordinator_client_from_test_override():
|
def coordinator_client_from_test_override():
|
||||||
"""Resolve every entry's coordinator client through the test seam.
|
"""Resolve every entry's coordinator client through the test seam.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import types
|
|||||||
|
|
||||||
import pytest
|
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 import api_client
|
||||||
from custom_components.nestquest.api_client import (
|
from custom_components.nestquest.api_client import (
|
||||||
@@ -33,6 +33,10 @@ from custom_components.nestquest.const import (
|
|||||||
DEFAULT_PANEL_TOKEN,
|
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"
|
BASE_URL = "http://api.test:8000"
|
||||||
TOKEN = "test-panel-token"
|
TOKEN = "test-panel-token"
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ class StubTransport:
|
|||||||
"url": url,
|
"url": url,
|
||||||
"headers": kwargs.get("headers"),
|
"headers": kwargs.get("headers"),
|
||||||
"json": kwargs.get("json"),
|
"json": kwargs.get("json"),
|
||||||
|
"timeout": kwargs.get("timeout"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not self._outcomes:
|
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 excinfo.value.status == 200
|
||||||
assert not excinfo.value.is_transport_error
|
assert not excinfo.value.is_transport_error
|
||||||
assert isinstance(excinfo.value.__cause__, ValueError)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -44,6 +44,10 @@ from custom_components.nestquest.api_client import (
|
|||||||
)
|
)
|
||||||
from custom_components.nestquest.sse import NestQuestEventStream
|
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"
|
BASE_URL = "http://api.test:8000"
|
||||||
TOKEN = "test-panel-token"
|
TOKEN = "test-panel-token"
|
||||||
|
|
||||||
|
|||||||