Files
buzeraandClaude Opus 5.5 ec3a0738fe 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>
2026-10-26 22:13:20 +02:00

105 lines
3.9 KiB
Python

"""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()