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:
1 parent
c79fdbd71b
commit
ec3a0738fe
8 files changed
+123
-1
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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()
|
||||||
@@ -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")
|
||||||
Reference in new issue
Block a user