diff --git a/CHANGELOG.md b/CHANGELOG.md index acbcee2..a884cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 comfort weight covered by the rooms used), and a per-room `used` flag in `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 diff --git a/README.md b/README.md index fb14bc8..4ccae6d 100644 --- a/README.md +++ b/README.md @@ -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. 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. ## Reconfigurable weather inputs @@ -155,6 +157,10 @@ run without Home Assistant installed: 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, scheduled sampling and poll cancellation, the relay listener, stored model restoration, retention and deletion, missing inputs, manual off/presets, optimizer diff --git a/custom_components/heating_mpc/brand/dark_icon.png b/custom_components/heating_mpc/brand/dark_icon.png new file mode 100644 index 0000000..1e4711e Binary files /dev/null and b/custom_components/heating_mpc/brand/dark_icon.png differ diff --git a/custom_components/heating_mpc/brand/dark_icon@2x.png b/custom_components/heating_mpc/brand/dark_icon@2x.png new file mode 100644 index 0000000..5a4b07f Binary files /dev/null and b/custom_components/heating_mpc/brand/dark_icon@2x.png differ diff --git a/custom_components/heating_mpc/brand/icon.png b/custom_components/heating_mpc/brand/icon.png new file mode 100644 index 0000000..9a4e919 Binary files /dev/null and b/custom_components/heating_mpc/brand/icon.png differ diff --git a/custom_components/heating_mpc/brand/icon@2x.png b/custom_components/heating_mpc/brand/icon@2x.png new file mode 100644 index 0000000..31143ac Binary files /dev/null and b/custom_components/heating_mpc/brand/icon@2x.png differ diff --git a/scripts/make_brand_icons.py b/scripts/make_brand_icons.py new file mode 100644 index 0000000..ffafb63 --- /dev/null +++ b/scripts/make_brand_icons.py @@ -0,0 +1,104 @@ +"""Render the integration's brand icons (radiator with a forecast curve). + +Home Assistant 2026.3+ shows custom_components//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() diff --git a/scripts/validate_package.py b/scripts/validate_package.py index 30923e1..a5c3217 100644 --- a/scripts/validate_package.py +++ b/scripts/validate_package.py @@ -1,6 +1,7 @@ """Small local packaging and translation checks; not a replacement for hassfest.""" import json from pathlib import Path +import struct import subprocess root = Path(__file__).resolve().parents[1] @@ -26,6 +27,14 @@ strings = json.loads((component / "strings.json").read_text()) for language in ("en", "ro"): translated = json.loads((component / "translations" / f"{language}.json").read_text()) 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() 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")