Add integration icon

Home Assistant 2026.3+ shows custom integrations' own brand images from
custom_components/<domain>/brand/. Add a radiator with a forecast curve
(solid past, "now" marker, dashed prediction) as icon.png/icon@2x.png
(256/512 px, transparent) plus dark-theme variants, so the integration
is easy to spot in Devices & services. Older HA versions ignore the
folder and show the generic placeholder; the integration is unaffected.

scripts/make_brand_icons.py regenerates the images with Pillow from the
test requirements; the package check verifies sizes and transparency.
Listed under 0.2.3, which has not been deployed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
buzeraandClaude Opus 5.5 committed 2026-10-26 22:13:20 +02:00
1 parent c79fdbd71b
commit ec3a0738fe
8 files changed
+123 -1

No files matched your search

+3
View File
@@ -25,6 +25,9 @@ A stale or failed room sensor no longer pauses the whole integration. Still
- Status attributes `excluded_rooms` and `weight_coverage` (share of the total - Status attributes `excluded_rooms` and `weight_coverage` (share of the total
comfort weight covered by the rooms used), and a per-room `used` flag in comfort weight covered by the rooms used), and a per-room `used` flag in
`input_quality`. `input_quality`.
- Integration icon (a radiator with a forecast curve) in **Settings → Devices &
services**, with a dark-theme variant. Needs Home Assistant 2026.3 or newer;
older versions show the generic placeholder.
### Upgrade notes ### Upgrade notes
+6
View File
@@ -14,6 +14,8 @@ This project targets Home Assistant OS on a Raspberry Pi 5, with one existing ON
6. Choose *ordered lists* of **outdoor sensor entity IDs**. First valid sensor is active; if missing, unavailable, nonnumeric or outside reasonable bounds, the next valid sensor is used automatically. 6. Choose *ordered lists* of **outdoor sensor entity IDs**. First valid sensor is active; if missing, unavailable, nonnumeric or outside reasonable bounds, the next valid sensor is used automatically.
7. Assign a relative comfort weight (0.1–10) to each selected room. 7. Assign a relative comfort weight (0.1–10) to each selected room.
On Home Assistant 2026.3 or newer the integration shows its own icon (a radiator with a forecast curve) in **Devices & services**; older versions show a generic placeholder.
**No entity IDs are hard-coded.** Every room and outdoor measurement can be changed later via **Configure**, without editing YAML or Python. **No entity IDs are hard-coded.** Every room and outdoor measurement can be changed later via **Configure**, without editing YAML or Python.
## Reconfigurable weather inputs ## Reconfigurable weather inputs
@@ -155,6 +157,10 @@ run without Home Assistant installed:
python -m unittest discover -s tests -v python -m unittest discover -s tests -v
``` ```
The brand icons in `custom_components/heating_mpc/brand/` are drawn by
`python scripts/make_brand_icons.py` (uses Pillow from the test requirements);
the package check verifies their sizes and transparency.
HA tests cover setup, config/options forms, migration, reload and entity cleanup, HA tests cover setup, config/options forms, migration, reload and entity cleanup,
scheduled sampling and poll cancellation, the relay listener, stored model scheduled sampling and poll cancellation, the relay listener, stored model
restoration, retention and deletion, missing inputs, manual off/presets, optimizer restoration, retention and deletion, missing inputs, manual off/presets, optimizer
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+104
View File
@@ -0,0 +1,104 @@
"""Render the integration's brand icons (radiator with a forecast curve).
Home Assistant 2026.3+ shows custom_components/<domain>/brand/ images in
Settings -> Devices & services. Development-only: needs Pillow, which the HA test
requirements already install. Run: python scripts/make_brand_icons.py
"""
from math import hypot
from pathlib import Path
from PIL import Image, ImageDraw
OUT = Path(__file__).resolve().parents[1] / "custom_components" / "heating_mpc" / "brand"
DESIGN = 512 # coordinates below are in a 512x512 design space
SUPERSAMPLE = 8
THEMES = {
"": {"radiator": "#F4511E", "forecast": "#1E88E5"}, # for light backgrounds
"dark_": {"radiator": "#FF7043", "forecast": "#64B5F6"}, # for dark backgrounds
}
def bezier(p0, p1, p2, p3, steps=400):
points = []
for i in range(steps + 1):
t = i / steps
u = 1 - t
points.append(tuple(u**3 * a + 3 * u * u * t * b + 3 * u * t * t * c + t**3 * d
for a, b, c, d in zip(p0, p1, p2, p3)))
return points
def stroke(draw, points, width, color):
"""Smooth round-capped stroke: overlapping discs along the path.
Pillow's wide polylines show seams between many short segments.
"""
r = width / 2
for (x0, y0), (x1, y1) in zip(points, points[1:]):
steps = max(1, int(hypot(x1 - x0, y1 - y0) / (r / 8)))
for i in range(steps + 1):
x, y = x0 + (x1 - x0) * i / steps, y0 + (y1 - y0) * i / steps
draw.ellipse((x - r, y - r, x + r, y + r), fill=color)
def split_dashes(points, dash, gap):
"""Cut a polyline into dash polylines of the given arc lengths."""
dashes, current, travelled, drawing = [], [points[0]], 0.0, True
for (x0, y0), (x1, y1) in zip(points, points[1:]):
travelled += hypot(x1 - x0, y1 - y0)
if drawing:
current.append((x1, y1))
limit = dash if drawing else gap
if travelled >= limit:
if drawing and len(current) > 1:
dashes.append(current)
current, travelled, drawing = [(x1, y1)], 0.0, not drawing
if drawing and len(current) > 1:
dashes.append(current)
return dashes
def render(colors):
s = SUPERSAMPLE
image = Image.new("RGBA", (DESIGN * s, DESIGN * s), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
scale = lambda pts: [(x * s, y * s) for x, y in pts]
# Radiator: five rounded columns joined by two headers.
fins, left, right, top, bottom = 5, 12, 500, 250, 500
gap = 22
width = (right - left - gap * (fins - 1)) / fins
for i in range(fins):
x0 = left + i * (width + gap)
draw.rounded_rectangle((x0 * s, top * s, (x0 + width) * s, bottom * s),
radius=width / 2 * s, fill=colors["radiator"])
for y in (296, 420):
draw.rectangle(((left + width / 2) * s, y * s, (right - width / 2) * s, (y + 34) * s),
fill=colors["radiator"])
# Forecast: measured past (solid), "now" marker, predicted future (dashed).
line = 40
curve = bezier((32, 192), (170, 192), (300, 146), (480, 32))
now = next(i for i, (x, _) in enumerate(curve) if x >= 220)
stroke(draw, scale(curve[:now + 1]), line * s, colors["forecast"])
for dash in split_dashes(curve[now:], 64, 44)[1:]:
stroke(draw, scale(dash), line * s, colors["forecast"])
x, y = curve[now]
radius = 40
draw.ellipse(((x - radius) * s, (y - radius) * s, (x + radius) * s, (y + radius) * s),
fill=colors["forecast"])
return image
def main():
OUT.mkdir(exist_ok=True)
for prefix, colors in THEMES.items():
image = render(colors)
for suffix, size in (("", 256), ("@2x", 512)):
path = OUT / f"{prefix}icon{suffix}.png"
image.resize((size, size), Image.LANCZOS).save(path, optimize=True)
print(f"wrote {path.relative_to(OUT.parents[2])}")
if __name__ == "__main__":
main()
+10 -1
View File
@@ -1,6 +1,7 @@
"""Small local packaging and translation checks; not a replacement for hassfest.""" """Small local packaging and translation checks; not a replacement for hassfest."""
import json import json
from pathlib import Path from pathlib import Path
import struct
import subprocess import subprocess
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
@@ -26,6 +27,14 @@ strings = json.loads((component / "strings.json").read_text())
for language in ("en", "ro"): for language in ("en", "ro"):
translated = json.loads((component / "translations" / f"{language}.json").read_text()) translated = json.loads((component / "translations" / f"{language}.json").read_text())
assert keys(strings) == keys(translated), f"Translation keys differ: {language}" assert keys(strings) == keys(translated), f"Translation keys differ: {language}"
# Brand icons (HA 2026.3+): square transparent PNGs; see scripts/make_brand_icons.py.
for name, size in (("icon.png", 256), ("icon@2x.png", 512),
("dark_icon.png", 256), ("dark_icon@2x.png", 512)):
png = (component / "brand" / name).read_bytes()
assert png[:8] == b"\x89PNG\r\n\x1a\n" and png[12:16] == b"IHDR", f"{name} is not a PNG"
width, height, _depth, color_type = struct.unpack(">IIBB", png[16:26])
assert (width, height) == (size, size), f"{name} is {width}x{height}, expected {size}x{size}"
assert color_type == 6, f"{name} needs an alpha channel"
tracked = subprocess.check_output(["git", "ls-files", "custom_components"], cwd=root, text=True).splitlines() tracked = subprocess.check_output(["git", "ls-files", "custom_components"], cwd=root, text=True).splitlines()
assert not any("__pycache__" in path or path.endswith((".pyc", ".pyo")) for path in tracked) assert not any("__pycache__" in path or path.endswith((".pyc", ".pyo")) for path in tracked)
print("Package metadata, translations and tracked component files passed") print("Package metadata, translations, brand icons and tracked component files passed")