diff --git a/README.md b/README.md index a857f87..24528f9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ NestQuest logo +

+ # NestQuest - House Chaos Coordination Home Assistant custom integration for managing chores, custody schedules, and house coordination. diff --git a/custom_components/nestquest/brand/icon.png b/custom_components/nestquest/brand/icon.png new file mode 100644 index 0000000..8c8e609 Binary files /dev/null and b/custom_components/nestquest/brand/icon.png differ diff --git a/custom_components/nestquest/brand/icon@2x.png b/custom_components/nestquest/brand/icon@2x.png new file mode 100644 index 0000000..e365d29 Binary files /dev/null and b/custom_components/nestquest/brand/icon@2x.png differ diff --git a/custom_components/nestquest/brand/logo.png b/custom_components/nestquest/brand/logo.png new file mode 100644 index 0000000..8c8e609 Binary files /dev/null and b/custom_components/nestquest/brand/logo.png differ diff --git a/custom_components/nestquest/brand/logo@2x.png b/custom_components/nestquest/brand/logo@2x.png new file mode 100644 index 0000000..e365d29 Binary files /dev/null and b/custom_components/nestquest/brand/logo@2x.png differ diff --git a/design/logos/BRAND-ICONS.md b/design/logos/BRAND-ICONS.md new file mode 100644 index 0000000..0617720 --- /dev/null +++ b/design/logos/BRAND-ICONS.md @@ -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//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. diff --git a/design/logos/generate-brand-icons.mjs b/design/logos/generate-brand-icons.mjs new file mode 100644 index 0000000..00e3562 --- /dev/null +++ b/design/logos/generate-brand-icons.mjs @@ -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); +} diff --git a/tests/test_brand_icons.py b/tests/test_brand_icons.py new file mode 100644 index 0000000..0295e8b --- /dev/null +++ b/tests/test_brand_icons.py @@ -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