Custom icon per tile: 150 MDI icons in firmware and editor

- screen_manager/app/tile_icons.py is the single icon list (12 groups, 20 media
  and music icons); tools/generate_icons.py writes it into the three icon fonts
  of both boards and builds the editor's subset font.
- Firmware 0.2.18 shows o.icon when its fonts carry the glyph, else the domain
  icon. The app sends the chosen icon, or Home Assistant's own mdi icon on auto.
- Tile sheet gets an icon picker with search; the mockup shows the icon the
  screen actually draws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 11:16:57 +02:00
parent efe82c9b09
commit ef0ca8b350
23 changed files with 1371 additions and 498 deletions
+24 -5
View File
@@ -6,6 +6,8 @@ import math
import re
import secrets
import tile_icons
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split())
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
BUILTIN = {'screen.clock': 'Klok'}
@@ -14,7 +16,7 @@ WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.17'
FIRMWARE_VERSION = '0.2.18'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level media_title options min max step temperature_unit supported_features next_rising next_setting finishes_at duration remaining'.split())
@@ -112,10 +114,12 @@ def validate_layout(data):
item = {'entity': tile['entity'], 'name': name.strip()}
if 'options' in tile:
options = tile['options']
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size'}:
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon'}:
raise ValueError('Onbekende tegelinstellingen.')
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
raise ValueError('Kies een pastel achtergrondkleur uit het palet.')
if 'icon' in options and not (options['icon'] == 'auto' or isinstance(options['icon'], str) and options['icon'] in tile_icons.ICONS):
raise ValueError('Kies een icoon uit de lijst.')
domain = tile['entity'].split('.')[0]
displays = DISPLAYS.get(domain, ('standard', 'watch'))
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
@@ -186,6 +190,19 @@ def extras(tile, states, forecast=None, tz=None):
return result or None
return None
def tile_icon(tile, attrs):
"""Codepoint the screen shows: the chosen icon, else HA's own mdi icon; None keeps the firmware default."""
choice = tile.get('options', {}).get('icon', 'auto')
return tile_icons.ICONS[choice][0] if choice in tile_icons.ICONS else tile_icons.ha_icon(attrs)
def screen_options(tile, attrs):
"""Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before)."""
options = {k: v for k, v in tile.get('options', {}).items() if k != 'icon'}
icon = tile_icon(tile, attrs)
if icon:
options['icon'] = icon
return options if options or 'options' in tile else None
def state_message(index, tile, states, extra=None):
if tile['entity'] in BUILTIN:
message = {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
@@ -210,10 +227,11 @@ def state_message(index, tile, states, extra=None):
limit = 2 if key == 'hs_color' else 4 if key == 'fan_speed_list' else 8
bounded[key] = [short(v, 48) if isinstance(v, str) else v for v in value[:limit]
if isinstance(v, str) or isinstance(v, (float, int)) and math.isfinite(v) and abs(v) <= 1000000]
options = screen_options(tile, attrs)
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
**({'o': tile['options']} if 'options' in tile else {}), **({'x': extra} if extra else {})}
**({'o': options} if options is not None else {}), **({'x': extra} if extra else {})}
def packets(message, token=None):
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode()
@@ -259,12 +277,13 @@ def discover(registry, states, devices, areas):
continue
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name') or item.get('name') or item.get('original_name') or eid,
'device': device.get('name_by_user') or device.get('name') or '', 'area': area,
'state': state.get('state', 'unavailable')})
'state': state.get('state', 'unavailable'), 'icon': tile_icons.ha_icon(state.get('attributes'))})
# YAML entities may not have an entity-registry entry.
registered = {e['id'] for e in entities}
for eid, state in states.items():
if entity_id(eid) and eid not in registered and not any(r['entity_id'] == eid for r in registry):
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name', eid), 'device': '', 'area': '', 'state': state['state']})
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name', eid), 'device': '', 'area': '', 'state': state['state'],
'icon': tile_icons.ha_icon(state.get('attributes'))})
return screens, sorted(entities, key=lambda e: e['name'].casefold())
def installation_yaml(data):
+5 -3
View File
@@ -10,6 +10,7 @@ import time
from datetime import datetime, timedelta, timezone
import math
from firmware import Firmware
import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
@@ -220,8 +221,9 @@ class Manager:
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
for tile in layout['tiles']:
old_options=old_tiles.get(tile['entity'],{}).get('options',{})
if 'options' in tile and 'background' not in tile['options'] and 'background' in old_options:
tile['options']['background']=old_options['background']
for key in ('background', 'icon'):
if 'options' in tile and key not in tile['options'] and key in old_options:
tile['options'][key]=old_options[key]
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
tile['options'] = old_tiles[tile['entity']]['options'].copy()
known = {e['id'] for e in self.inventory()[1]} | set(BUILTIN)
@@ -361,7 +363,7 @@ def create_app(manager, development=False):
screen['update'] = manager.updates.state_for(screen)
builtin = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities,
'backgrounds': TILE_BACKGROUNDS, 'builtin': builtin, 'updates': manager.updates.summary()})
'backgrounds': TILE_BACKGROUNDS, 'icons': tile_icons.editor(), 'builtin': builtin, 'updates': manager.updates.summary()})
async def save(request):
manager.save(request.match_info['inbox'], await request.json())
return web.json_response({'saved': True})
+114 -3
View File
@@ -405,6 +405,29 @@ function domainBadge(id) {
badge.style.background = background;
return badge;
}
// The screen draws Material Design Icons; static/tile-icons.woff holds the same glyphs.
const glyph = (cp) => String.fromCodePoint(parseInt(cp, 16));
let iconIndex = { source: null, byName: {} };
function iconNamed(name) {
if (iconIndex.source !== inventory.icons)
iconIndex = { source: inventory.icons, byName: Object.fromEntries((inventory.icons?.groups || []).flatMap((g) => g.icons.map((i) => [i.name, i]))) };
return iconIndex.byName[name];
}
// What the firmware draws without a choice: Home Assistant's own icon, else the domain icon.
function automaticIcon(id) {
const icons = inventory.icons, entity = inventory.entities.find((e) => e.id === id), domain = id.split(".")[0];
if (entity?.icon) return entity.icon;
if (domain === "weather") return icons.weather[entity?.state] || icons.weather.partlycloudy;
if (domain === "sun") return icons.sun[entity?.state] || icons.sun.below_horizon;
return icons.defaults[domain] || icons.fallback;
}
function tileBadge(tile) {
const badge = domainBadge(tile.entity);
if (!inventory.icons) return badge;
badge.textContent = glyph(iconNamed(tile.options?.icon)?.cp || automaticIcon(tile.entity));
badge.classList.add("mdi");
return badge;
}
function tileLimit() {
const version=inventory.screens.find(s=>s.id===selected)?.firmware || "";
const match=/^(\d+)\.(\d+)\.(\d+)$/.exec(version);
@@ -437,7 +460,7 @@ function renderPreview() {
// "Geen": no card on the screen; the mockup keeps a dashed outline as drop target.
if (tile.options?.background === "none") card.classList.add("bare");
if (place.wide) card.classList.add("wide");
card.append(domainBadge(tile.entity), node("strong", name));
card.append(tileBadge(tile), node("strong", name));
card.dataset.index = index;
enableDrag(card, { kind: "tile", index });
card.classList.toggle("chosen", selectedTile === tile.entity);
@@ -487,8 +510,9 @@ function removeTile(index) {
}
// Tile settings open in a sheet above the mockup; every change applies live,
// so the card behind it shows the result while you pick.
let sheetIndex = -1;
let sheetIndex = -1, iconPickerOpen = false;
function openTileSheet(index) {
if (index !== sheetIndex) iconPickerOpen = false;
sheetIndex = index;
selectedTile = layout.tiles[index].entity;
renderTileSheet();
@@ -520,6 +544,89 @@ function segmented(choices, value, onChange) {
}
return group;
}
// A choice applies live like the palette: only pressed states, the summary and the
// badges update, so search text and scroll position survive.
function iconField(tile, onChange) {
const wrap = node("div", undefined, "sheet-field");
wrap.append(node("span", "Icoon"));
const fromHA = Boolean(inventory.entities.find((e) => e.id === tile.entity)?.icon);
const autoLabel = `Automatisch (${fromHA ? "uit Home Assistant" : "standaard"})`;
const summary = node("button", undefined, "icon-current");
summary.type = "button";
summary.setAttribute("aria-expanded", String(iconPickerOpen));
const current = node("span", undefined, "mdi"), text = node("span"), action = node("small", iconPickerOpen ? "Sluiten" : "Wijzigen");
summary.append(current, text, action);
const describe = () => {
const chosen = iconNamed(tile.options?.icon);
current.textContent = glyph(chosen?.cp || automaticIcon(tile.entity));
text.textContent = chosen?.label || autoLabel;
};
describe();
const panel = node("div", undefined, "icon-picker");
panel.hidden = !iconPickerOpen;
summary.onclick = () => {
iconPickerOpen = panel.hidden;
panel.hidden = !iconPickerOpen;
summary.setAttribute("aria-expanded", String(iconPickerOpen));
action.textContent = iconPickerOpen ? "Sluiten" : "Wijzigen";
};
const choice = (name, cp, label, cls = "") => {
const b = node("button", undefined, `icon-choice ${cls}`);
b.type = "button";
b.dataset.icon = name;
b.dataset.search = label.toLocaleLowerCase();
b.title = label;
b.setAttribute("aria-label", `Icoon: ${label}`);
b.setAttribute("aria-pressed", String((tile.options?.icon || "auto") === name));
b.append(node("span", glyph(cp), "mdi"));
b.onclick = () => {
tile.options = { ...tile.options, icon: name };
for (const other of panel.querySelectorAll(".icon-choice")) other.setAttribute("aria-pressed", String(other === b));
describe();
markDirty();
renderPreview();
onChange();
};
return b;
};
const search = node("input");
search.type = "search";
search.placeholder = "Zoek, bijvoorbeeld lamp, muziek of deur";
search.setAttribute("aria-label", "Zoek een icoon");
const auto = choice("auto", automaticIcon(tile.entity), autoLabel, "icon-auto");
auto.append(node("span", autoLabel));
const list = node("div", undefined, "icon-list"), empty = node("p", "Geen icoon gevonden.", "hint");
empty.hidden = true;
const sections = inventory.icons.groups.map((group) => {
const section = node("section"), grid = node("div", undefined, "icon-grid");
for (const icon of group.icons) {
const b = choice(icon.name, icon.cp, icon.label);
b.dataset.search += ` ${icon.name.replaceAll("-", " ")} ${group.label.toLocaleLowerCase()}`;
grid.append(b);
}
section.append(node("small", group.label), grid);
list.append(section);
return section;
});
search.oninput = () => {
const query = search.value.trim().toLocaleLowerCase();
let found = 0;
for (const section of sections) {
let visible = 0;
for (const b of section.querySelectorAll(".icon-choice")) {
b.hidden = !b.dataset.search.includes(query);
if (!b.hidden) visible++;
}
section.hidden = !visible;
found += visible;
}
empty.hidden = found > 0;
};
panel.append(search, auto, list, empty);
wrap.append(summary, panel);
if (!supportsFirmware(0, 2, 18)) wrap.append(node("small", "Het scherm toont een gekozen icoon vanaf firmware 0.2.18.", "icon-hint"));
return wrap;
}
function renderTileSheet() {
const sheet = $("#tile-sheet"), tile = layout.tiles[sheetIndex];
if (!tile) return;
@@ -531,7 +638,8 @@ function renderTileSheet() {
close.type = "button";
close.setAttribute("aria-label", "Sluiten");
close.onclick = closeTileSheet;
head.append(domainBadge(tile.entity), titles, close);
let badge = tileBadge(tile);
head.append(badge, titles, close);
const body = node("div", undefined, "sheet-body");
const nameInput = node("input");
nameInput.value = tile.name;
@@ -539,6 +647,9 @@ function renderTileSheet() {
nameInput.maxLength = 60;
nameInput.oninput = () => { tile.name = nameInput.value; markDirty(); renderPreview(); };
body.append(field("Naam op het scherm", nameInput));
// The clock, forecast and sun path cards draw no tile icon.
if (inventory.icons && domain !== "screen" && !["forecast", "sunpath"].includes(tile.options?.display))
body.append(iconField(tile, () => { const next = tileBadge(tile); badge.replaceWith(next); badge = next; }));
const displays = domain === "screen"
? [["digital", "Digitale klok"], ["analog", "Analoge klok"]]
: [["standard", "Naam en status"], ["watch", "Grote waarde"]];
+22
View File
@@ -957,3 +957,25 @@ pre {
.tile-sheet { width: 100%; max-width: 100%; margin: auto 0 0; border-radius: 20px 20px 0 0; max-height: 90vh; }
.sheet-body { max-height: 52vh; }
}
/* Tile icons: the same Material Design Icons subset the screen's fonts carry. */
@font-face { font-family: "Tile Icons"; src: url("tile-icons.woff") format("woff"); font-display: block; }
.mdi { font-family: "Tile Icons"; font-style: normal; font-weight: normal; line-height: 1; }
.domain-icon.mdi { font-size: 22px; }
.preview-tile .domain-icon.mdi { font-size: 19px; }
.icon-current { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 12px 7px 7px; background: white; color: var(--ink); border: 1px solid #cdd5df; border-radius: 12px; font-size: 14px; font-weight: 500; text-align: left; }
.icon-current .mdi { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: 9px; background: #f3f5f8; font-size: 24px; }
.icon-current > span:nth-child(2) { flex: 1; min-width: 0; }
.icon-current small { color: var(--accent); font-weight: 600; }
.icon-picker { display: grid; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: 12px; background: #fafbfc; }
.icon-picker input { font-size: 14px; font-weight: 400; }
.icon-list { display: grid; gap: 12px; max-height: 300px; overflow: auto; padding-right: 2px; }
.icon-list small { display: block; margin-bottom: 6px; }
.icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 6px; }
.icon-choice { display: grid; place-items: center; height: 44px; padding: 0; background: white; color: var(--ink); border: 1px solid var(--line); border-radius: 10px; }
.icon-choice .mdi { font-size: 25px; }
.icon-choice[aria-pressed="true"] { background: var(--soft); color: var(--accent); border-color: #9dbcf5; box-shadow: inset 0 0 0 1px #9dbcf5; }
.icon-choice.icon-auto { display: flex; justify-content: flex-start; gap: 10px; padding: 0 12px; font-size: 13px; font-weight: 500; }
.icon-choice.icon-auto .mdi { font-size: 22px; }
.icon-picker .hint { margin: 0; }
.icon-hint { font-weight: 400; }
Binary file not shown.
+226
View File
@@ -0,0 +1,226 @@
"""Curated Material Design Icons for tiles, shared by the editor and both firmware boards.
This is the only list. `python3 tools/generate_icons.py` writes these glyphs into
the three icon fonts of both board profiles and builds the editor font
`static/tile-icons.woff`; `--check` verifies names and codepoints against
fonts/materialdesignicons-webfont.ttf. Firmware 0.2.18+ carries every glyph here.
"""
# (group, ((MDI name, codepoint, label), ...)) in picker order.
GROUPS = (
('Verlichting', (
('lightbulb', 'F0335', 'Lamp'),
('lightbulb-group', 'F1253', 'Lampen'),
('ceiling-light', 'F0769', 'Plafondlamp'),
('chandelier', 'F1793', 'Kroonluchter'),
('lamp', 'F06B5', 'Tafellamp'),
('floor-lamp', 'F08DD', 'Vloerlamp'),
('desk-lamp', 'F095F', 'Bureaulamp'),
('wall-sconce-flat', 'F091D', 'Wandlamp'),
('light-recessed', 'F179B', 'Inbouwspot'),
('spotlight-beam', 'F04C9', 'Spot'),
('led-strip-variant', 'F1051', 'Ledstrip'),
('outdoor-lamp', 'F1054', 'Buitenlamp'),
)),
('Ruimtes', (
('home', 'F02DC', 'Huis'),
('sofa', 'F04B9', 'Woonkamer'),
('bed', 'F02E3', 'Slaapkamer'),
('silverware-fork-knife', 'F0A70', 'Eetkamer'),
('chef-hat', 'F0B7C', 'Keuken'),
('shower', 'F09A0', 'Douche'),
('bathtub', 'F1818', 'Badkamer'),
('toilet', 'F09AB', 'Toilet'),
('desk', 'F1239', 'Werkkamer'),
('garage', 'F06D9', 'Garage'),
('fireplace', 'F0E2E', 'Open haard'),
)),
('Klimaat', (
('thermometer', 'F050F', 'Temperatuur'),
('thermostat', 'F0393', 'Thermostaat'),
('air-conditioner', 'F001B', 'Airco'),
('radiator', 'F0438', 'Radiator'),
('heat-pump', 'F1A43', 'Warmtepomp'),
('fire', 'F0238', 'Verwarming'),
('snowflake', 'F0717', 'Koelen'),
('water-percent', 'F058E', 'Luchtvochtigheid'),
('fan', 'F0210', 'Ventilator'),
('ceiling-fan', 'F1797', 'Plafondventilator'),
('air-purifier', 'F0D44', 'Luchtreiniger'),
('molecule-co2', 'F07E4', 'CO₂'),
)),
('Weer', (
('weather-sunny', 'F0599', 'Zon'),
('weather-partly-cloudy', 'F0595', 'Half bewolkt'),
('weather-cloudy', 'F0590', 'Bewolkt'),
('weather-rainy', 'F0597', 'Regen'),
('weather-snowy', 'F0598', 'Sneeuw'),
('weather-windy', 'F059D', 'Wind'),
('weather-night', 'F0594', 'Nacht'),
('weather-sunset-up', 'F059C', 'Zonsopkomst'),
)),
('Media en muziek', (
('music-note', 'F0387', 'Muziek'),
('music', 'F075A', 'Muzieknoten'),
('playlist-music', 'F0CB8', 'Afspeellijst'),
('album', 'F0025', 'Album'),
('record-player', 'F099A', 'Platenspeler'),
('spotify', 'F04C7', 'Spotify'),
('radio', 'F0439', 'Radio'),
('podcast', 'F0994', 'Podcast'),
('speaker', 'F04C3', 'Speaker'),
('speaker-multiple', 'F0D38', 'Speakers'),
('soundbar', 'F17DB', 'Soundbar'),
('headphones', 'F02CB', 'Koptelefoon'),
('piano', 'F067D', 'Piano'),
('guitar-acoustic', 'F0771', 'Gitaar'),
('television', 'F0502', 'Televisie'),
('projector', 'F042E', 'Beamer'),
('movie-open', 'F0FCE', 'Film'),
('cast', 'F0118', 'Casten'),
('gamepad-variant', 'F0297', 'Gameconsole'),
('remote-tv', 'F0EC5', 'Afstandsbediening'),
)),
('Beveiliging', (
('lock', 'F033E', 'Slot'),
('lock-open-variant', 'F0FC6', 'Slot open'),
('shield-home', 'F068A', 'Alarm'),
('alarm-light', 'F078F', 'Alarmlicht'),
('bell', 'F009A', 'Bel'),
('doorbell', 'F12E6', 'Deurbel'),
('cctv', 'F07AE', 'Camera'),
('motion-sensor', 'F0D91', 'Bewegingsmelder'),
('door-closed', 'F081B', 'Deur'),
('door-open', 'F081C', 'Deur open'),
('window-closed-variant', 'F11DB', 'Raam'),
('smoke-detector', 'F0392', 'Rookmelder'),
('water-alert', 'F1502', 'Waterlek'),
)),
('Apparaten', (
('washing-machine', 'F072A', 'Wasmachine'),
('tumble-dryer', 'F0917', 'Droger'),
('dishwasher', 'F0AAC', 'Vaatwasser'),
('fridge', 'F0290', 'Koelkast'),
('stove', 'F04DE', 'Fornuis'),
('microwave', 'F0C99', 'Magnetron'),
('toaster-oven', 'F0CD3', 'Oven'),
('coffee-maker', 'F109F', 'Koffie'),
('kettle', 'F05FA', 'Waterkoker'),
('robot-vacuum', 'F070D', 'Robotstofzuiger'),
('robot-mower', 'F11F7', 'Robotmaaier'),
('printer-3d', 'F042B', '3D-printer'),
('power-plug', 'F06A5', 'Stekker'),
('power-socket-eu', 'F07E7', 'Stopcontact'),
('laptop', 'F0322', 'Laptop'),
('router-wireless', 'F0469', 'Router'),
)),
('Energie', (
('flash', 'F0241', 'Stroom'),
('solar-power', 'F0A72', 'Zonne-energie'),
('solar-panel', 'F0D9B', 'Zonnepanelen'),
('home-battery', 'F1901', 'Thuisbatterij'),
('battery-high', 'F12A3', 'Batterij'),
('ev-station', 'F05F1', 'Laadpaal'),
('car-electric', 'F0B6C', 'Elektrische auto'),
('meter-electric', 'F1A57', 'Stroommeter'),
('meter-gas', 'F1A59', 'Gasmeter'),
('water', 'F058C', 'Water'),
('water-boiler', 'F0F92', 'Boiler'),
('gauge', 'F029A', 'Meter'),
)),
('Zonwering', (
('window-shutter', 'F111C', 'Rolluik'),
('window-shutter-open', 'F111E', 'Rolluik open'),
('blinds', 'F00AC', 'Jaloezie'),
('blinds-open', 'F1011', 'Jaloezie open'),
('roller-shade-closed', 'F1A6C', 'Rolgordijn'),
('curtains', 'F1846', 'Gordijnen'),
('curtains-closed', 'F1847', 'Gordijnen dicht'),
('awning-outline', 'F1B88', 'Zonnescherm'),
)),
('Tuin en huisdieren', (
('flower', 'F024A', 'Bloem'),
('tree', 'F0531', 'Boom'),
('sprout', 'F0E66', 'Plant'),
('grass', 'F1510', 'Gazon'),
('sprinkler', 'F105F', 'Sproeier'),
('pool', 'F0606', 'Zwembad'),
('grill', 'F0E45', 'Barbecue'),
('mailbox', 'F06EE', 'Brievenbus'),
('trash-can', 'F0A79', 'Afval'),
('recycle', 'F044C', 'Recycling'),
('dog', 'F0A43', 'Hond'),
('cat', 'F011B', 'Kat'),
)),
('Mensen en onderweg', (
('account', 'F0004', 'Persoon'),
('account-group', 'F0849', 'Gezin'),
('account-child', 'F0A89', 'Kind'),
('home-account', 'F0826', 'Thuis'),
('sleep', 'F04B2', 'Slapen'),
('run', 'F070E', 'Sporten'),
('briefcase', 'F00D6', 'Werk'),
('car', 'F010B', 'Auto'),
('bike', 'F00A3', 'Fiets'),
('airplane', 'F001D', 'Vakantie'),
('map-marker', 'F034E', 'Locatie'),
)),
('Overig', (
('power', 'F0425', 'Aan/uit'),
('toggle-switch', 'F0521', 'Schakelaar'),
('gesture-tap-button', 'F12A8', 'Knop'),
('play', 'F040A', 'Start'),
('script-text', 'F0BC2', 'Script'),
('palette', 'F03D8', 'Scène'),
('alarm', 'F0020', 'Wekker'),
('clock-outline', 'F0150', 'Klok'),
('timer-outline', 'F051B', 'Timer'),
('calendar', 'F00ED', 'Agenda'),
('bell-ring', 'F009E', 'Melding'),
('heart', 'F02D1', 'Favoriet'),
('alert-outline', 'F002A', 'Waarschuwing'),
('cog', 'F0493', 'Instellingen'),
('broom', 'F00E2', 'Schoonmaken'),
)),
)
# Glyphs the firmware draws itself (weather conditions, sun, checkmark) that the picker does not offer.
FIXED = (
('alert-circle-outline', 'F05D6'),
('check', 'F012C'),
('weather-fog', 'F0591'),
('weather-hail', 'F0592'),
('weather-lightning', 'F0593'),
('weather-lightning-rainy', 'F067E'),
('weather-pouring', 'F0596'),
('weather-snowy-rainy', 'F067F'),
('weather-sunset-down', 'F059B'),
)
# Pickable icons by name, and every glyph the firmware fonts contain.
ICONS = {name: (codepoint, label) for _, icons in GROUPS for name, codepoint, label in icons}
GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(FIXED)}
# Mirrors runtime_tiles::icon_for() so the editor mockup shows what the screen draws.
DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot-vacuum', 'fan': 'fan',
'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge',
'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'}
FALLBACK = 'power'
WEATHER = {'sunny': 'weather-sunny', 'clear-night': 'weather-night', 'cloudy': 'weather-cloudy',
'partlycloudy': 'weather-partly-cloudy', 'rainy': 'weather-rainy', 'pouring': 'weather-pouring',
'snowy': 'weather-snowy', 'snowy-rainy': 'weather-snowy-rainy', 'fog': 'weather-fog', 'hail': 'weather-hail',
'lightning': 'weather-lightning', 'lightning-rainy': 'weather-lightning-rainy', 'windy': 'weather-windy',
'windy-variant': 'weather-windy', 'exceptional': 'alert-circle-outline'}
def ha_icon(attributes):
"""Codepoint of Home Assistant's own `mdi:` icon when the firmware carries it, else None."""
icon = attributes.get('icon') if isinstance(attributes, dict) else None
return GLYPHS.get(icon[4:]) if isinstance(icon, str) and icon.startswith('mdi:') else None
def editor():
"""Picker groups plus what the mockup needs to predict the screen's own icons."""
return {'groups': [{'label': group, 'icons': [{'name': name, 'cp': codepoint, 'label': label} for name, codepoint, label in icons]}
for group, icons in GROUPS],
'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK],
'weather': {state: GLYPHS[name] for state, name in WEATHER.items()},
'sun': {'above_horizon': GLYPHS['weather-sunset-down'], 'below_horizon': GLYPHS['weather-sunset-up']}}