diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ac8960f..a96272b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,31 @@ # NestQuest Release Notes +## 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 diff --git a/admin/public/icons/icon-192.png b/admin/public/icons/icon-192.png index 4435870..c63b895 100644 Binary files a/admin/public/icons/icon-192.png and b/admin/public/icons/icon-192.png differ diff --git a/admin/public/icons/icon-512.png b/admin/public/icons/icon-512.png index 63419f2..c88ee88 100644 Binary files a/admin/public/icons/icon-512.png and b/admin/public/icons/icon-512.png differ diff --git a/admin/public/icons/icon-maskable-512.png b/admin/public/icons/icon-maskable-512.png index ae98c76..b07b68b 100644 Binary files a/admin/public/icons/icon-maskable-512.png and b/admin/public/icons/icon-maskable-512.png differ diff --git a/admin/scripts/generate-icons.mjs b/admin/scripts/generate-icons.mjs index 89cd01f..2acff9d 100644 --- a/admin/scripts/generate-icons.mjs +++ b/admin/scripts/generate-icons.mjs @@ -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}`); } diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index dec18bf..5e35a81 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues", "requirements": [], - "version": "0.10.2" + "version": "0.10.3" } diff --git a/pyproject.toml b/pyproject.toml index 44d3d35..e0591d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestquest" -version = "0.10.2" +version = "0.10.3" description = "NestQuest Home Assistant Integration" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 0e02c5b..b6fdfcd 100644 --- a/uv.lock +++ b/uv.lock @@ -373,7 +373,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.10.2" +version = "0.10.3" source = { virtual = "." } [package.dev-dependencies]