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>
|
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 |
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||