Ship the NestQuest brand images in the integration's brand directory (task ac84fa96)

Home Assistant 2026.3+ serves custom_components/<domain>/brand/ ahead of the
brands CDN, so generate icon/logo PNGs (256 and 512, RGBA) into
custom_components/nestquest/brand/, which HACS installs with the integration.
Move the generator to design/logos/, add a --check mode, and drop the
obsolete brands-repository staging folder. Add tests that the four images
exist with the right size and reproduce byte-identically. Rewrite the note
to describe the local brand directory and the unverified HACS store card
caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 16:42:58 -04:00
co-authored by Claude Opus 5.5
parent 929a61e9c7
commit e145fdf1bb
8 changed files with 136 additions and 41 deletions

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 191 KiB

+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.
@@ -1,31 +0,0 @@
# Home Assistant brands files for NestQuest
These files are prepared for a pull request to
[`github.com/home-assistant/brands`](https://github.com/home-assistant/brands),
placed at `custom_integrations/nestquest/`:
| File | Size |
|------|------|
| `icon.png` | 256×256 |
| `icon@2x.png` | 512×512 |
| `logo.png` | 256×256 |
| `logo@2x.png` | 512×512 |
Home Assistant and HACS load integration icons from `brands.home-assistant.io`,
which is built from that repository. Once the pull request is merged, the
NestQuest icon shows on the HACS card and on the Home Assistant integrations
page. Nothing in this repository can change that icon directly.
The alternative (HACS only) is to set the avatar of the GitHub
`Maniacs-Mansion` organization to the NestQuest logo; HACS falls back to the
repository owner's avatar when no brands icon exists.
## 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/brands/generate-brand-icons.mjs
```
@@ -1,10 +1,12 @@
// Generates the Home Assistant brands files under
// custom_integrations/nestquest/ from the transparent logo
// (design/logos/NestQuest-NoBG.png), box-filter downscaled with alpha kept.
// Adapted from admin/scripts/generate-icons.mjs for RGBA input/output.
// 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/brands/generate-brand-icons.mjs
import { readFileSync, writeFileSync } from "node:fs";
// 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";
@@ -149,16 +151,37 @@ function resize({ width, height, px }, size) {
return out;
}
const logo = decodePng(readFileSync(new URL("../NestQuest-NoBG.png", import.meta.url)));
const outDir = new URL("custom_integrations/nestquest/", import.meta.url);
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));
writeFileSync(path, encodePng(size, resize(logo, size)));
console.log(`wrote ${path}`);
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);
}
+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