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>
105 lines
3.9 KiB
Python
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()
|