Let every screen choose its top bar: clocks, date and entities (0.2.38)

The name stays on the left; up to six items go on the right: the time, an
analog clock, the date, or a Home Assistant entity with its icon, showing its
state or how long ago it changed, always or only while active. The add-on
formats entity text in Dutch and sends it as op: header right after the layout
to firmware 0.2.32+; older firmware keeps the clock of show_clock, which now
follows the clock item.

The firmware draws every value in one font on the name's baseline, centres
icons and the dial on the digit height and measures gaps between glyph ink, so
the bar looks even; the editor's mockup uses Roboto and the same integer rules.
tools/render_topbar.py renders the real LVGL code on a computer (ESPHome host +
SDL2). The editor's section buttons are gone.

Firmware 0.2.32 for both boards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-14 18:38:49 +02:00
parent ae308e37ab
commit 3f6889f750
25 changed files with 2365 additions and 94 deletions
+2
View File
@@ -65,6 +65,8 @@ fysiek tikken; een agent kan dat niet vervangen door softwarecoördinaten.
`diagnostics/send_layout.py` zet een demo-indeling met alle kaarttypen op een
scherm via de API-inbox (geen HA-acties); de manager herstelt de echte
indeling binnen ~25 s. Guition: `capture_ui.py` bewaart de LVGL-render als PNG.
Zonder scherm: `tools/render_topbar.py` rendert de echte bovenbalkcode van beide
borden via ESPHome host + SDL2 naar `.esphome/render-topbar/out/sheet.png`.
- Deel via `tools/export_bundle.py` of Git. Stage geen secrets, metingen,
binaries, logs, buildcache of lokale apparaatprofielen.
- Geen automatische firmware-upload naar een willekeurige aangesloten poort.
+34 -3
View File
@@ -58,8 +58,14 @@ De firmware en ingebouwde CLI zijn getest met **ESPHome 2026.6.2**.
millimeters; **klimaatkaart** met aan/uit-knop, modus-, ventilator- en zwenkstanden.
Scènes, scripts en knoppen tonen wanneer ze voor het laatst liepen. Een tegel die op
Home Assistant wacht toont een klein spinnertje op een lichte laag.
- **Bovenbalk per scherm** (firmware 0.2.32+): links de naam, rechts tot zes
onderdelen naar keuze: de tijd, een analoge klok, de datum, of een entiteit uit
Home Assistant met icoon, zoals temperatuur, luchtvochtigheid, verbruik, een deur
(open/dicht), het alarm, wie er thuis is of wanneer iemand of iets het laatst
veranderde ("5 min geleden", "Gisteren"). Zie [Bovenbalk](#bovenbalk).
- **Scherminstellingen:** standby-tijd, normale en gedimde helderheid,
nachturen, klok, terug naar de hoofdpagina en optioneel swipen tussen pagina's.
nachturen, 24- of 12-uursklok, terug naar de hoofdpagina en optioneel swipen
tussen pagina's.
- **Guition-rotatie:** 0°, 90°, 180° of 270°, direct vanuit de beheerpagina.
De native LVGL-rotatie draait beeld en touch samen. De CYD behoudt zijn vaste
oriëntatie en eigen kalibratie.
@@ -169,8 +175,7 @@ installatieprofiel aanmaken genereert nieuwe sleutels.
## Tegels en kleuren aanpassen
Open **Tegels instellen**, klik een tegel in het schermvoorbeeld en open
**Bediening & weergave instellen**. Kies bij **Pastel achtergrond** een kleur,
Klik een tegel in het schermvoorbeeld. Kies bij **Pastel achtergrond** een kleur,
zoals rood of groen. Pas eventueel de naam, klikactie, mini-slider of grote
waarde aan. Klik **Opslaan & naar scherm** om de wijzigingen toe te passen.
Dit vereist na de eerste ondersteunende firmware-update geen nieuwe flash.
@@ -179,6 +184,32 @@ Een kleur is een vaste keuze voor die tegel: hij blijft dus bijvoorbeeld rood
wanneer je het alles-uit-script gebruikt. De entiteitsstatus en actiefeedback
blijven afzonderlijk zichtbaar.
## Bovenbalk
Bovenaan de editor staat per scherm de **Bovenbalk**: links de naam, rechts tot
zes onderdelen. **+ Toevoegen** biedt de tijd, een analoge klok en de datum (die
tellen op het scherm zelf door, ook zonder Home Assistant), suggesties uit je eigen
huis (temperatuur en verbruik uit de ruimte van het scherm, het weer, aantal thuis,
zon op en onder) en een zoekveld voor elke entiteit, ook een telefoon
(`device_tracker`), een slot, het alarmpaneel of `zone.home`. Sleep de onderdelen
om de volgorde te wijzigen; tik erop om ze in te stellen:
- **Wat laten zien:** de status zoals Home Assistant hem schrijft (21,3 °C, 65%,
1.249 W, Open/Dicht, Thuis/Weg, Afwezig), of **Laatst gewijzigd**: "Zojuist",
"5 min geleden", "Gisteren". Een tijdstempel-sensor telt ook vooruit ("Over 2 uur").
- **Icoon:** automatisch zoals Home Assistant (een open deur krijgt een open
deur-icoon), een eigen icoon uit de lijst of geen icoon.
- **Tonen:** altijd, of **alleen als actief**: het onderdeel verschijnt pas als het
aan, open, thuis of meer dan 0 is. Handig voor een open deur of een draaiende
wasmachine. Actieve onderdelen kleuren zoals in Home Assistant (open deur amber,
alarm ingeschakeld groen, alarm afgegaan rood).
Het schermvoorbeeld tekent de balk met dezelfde letters en regels als het scherm:
alle waarden op één lijn met de naam, iconen op cijferhoogte, gelijke afstanden.
Past niet alles naast de naam, dan krijgt de naam puntjes en laat het scherm de
voorste onderdelen weg; de editor markeert die met strepen. Oudere firmware toont
tot de update alleen de naam en de tijd (als die in de balk staat).
## Updates en behoud van je instellingen
| Wijziging | Actie |
+128
View File
@@ -0,0 +1,128 @@
#pragma once
#include <algorithm>
#include <array>
#include <cstdint>
#include <string>
namespace header_bar {
// The top bar right of the screen name (firmware 0.2.32+). ESP Screen Manager decides what shows
// and writes an entity's text; the screen draws it, ticks its clocks and counts "5 min geleden"
// itself. Everything here is free of LVGL, so tests/test_header_bar.cpp covers it on a PC.
constexpr size_t MAX_ITEMS = 6;
constexpr size_t TEXT_BYTES = 48;
enum class Kind : uint8_t { none, clock, analog, date, text, ago };
inline Kind kind(const std::string &name) {
if (name == "clock") return Kind::clock;
if (name == "analog") return Kind::analog;
if (name == "date") return Kind::date;
if (name == "text") return Kind::text;
if (name == "ago") return Kind::ago;
return Kind::none;
}
struct Item {
Kind kind = Kind::none;
uint32_t icon = 0; // Material Design Icons codepoint, 0 without an icon
std::string text; // Kind::text
int64_t epoch = 0; // Kind::ago: the moment, past or future
uint32_t color = 0; // accent of the icon while `has_color`
bool has_color = false;
bool operator==(const Item &o) const {
return kind == o.kind && icon == o.icon && text == o.text && epoch == o.epoch && color == o.color && has_color == o.has_color;
}
};
struct Bar {
std::array<Item, MAX_ITEMS> items{};
size_t count = 0;
bool received = false; // false until the manager sent one: the clock of show_clock then
};
// "RRGGBB" as 0xRRGGBB; false for anything else.
inline bool color(const std::string &hex, uint32_t &out) {
if (hex.size() != 6) return false;
uint32_t value = 0;
for (char c : hex) {
int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'A' && c <= 'F' ? c - 'A' + 10 : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
if (digit < 0) return false;
value = value << 4 | static_cast<uint32_t>(digit);
}
out = value;
return true;
}
// Next codepoint of UTF-8 text at `i` (advanced past it); 0 at the end. Broken bytes count as one.
inline uint32_t next_codepoint(const std::string &s, size_t &i) {
if (i >= s.size()) return 0;
unsigned char c = static_cast<unsigned char>(s[i]);
int extra = c >= 0xF0 ? 3 : c >= 0xE0 ? 2 : c >= 0xC0 ? 1 : 0;
uint32_t cp = extra == 3 ? c & 0x07 : extra == 2 ? c & 0x0F : extra == 1 ? c & 0x1F : c;
++i;
for (int k = 0; k < extra && i < s.size() && (static_cast<unsigned char>(s[i]) & 0xC0) == 0x80; ++k, ++i)
cp = cp << 6 | (static_cast<unsigned char>(s[i]) & 0x3F);
return cp;
}
// Relative time in the editor's words (app.js agoText): "Zojuist", "5 min geleden", "Gisteren",
// "Over 2 uur". `now` 0 means the clock is not set yet.
inline std::string ago_text(int64_t then, int64_t now) {
if (now <= 0 || then <= 0) return "—";
int64_t seconds = now - then, span = seconds < 0 ? -seconds : seconds;
auto n = [&](int64_t unit) { return std::to_string(span / unit); };
if (seconds < 0) {
if (span < 3600) return "Over " + std::to_string(std::max<int64_t>(1, span / 60)) + " min";
if (span < 86400) return "Over " + n(3600) + " uur";
if (span < 172800) return "Morgen";
return "Over " + n(86400) + " dagen";
}
if (span < 60) return "Zojuist";
if (span < 3600) return n(60) + " min geleden";
if (span < 86400) return n(3600) + " uur geleden";
if (span < 172800) return "Gisteren";
if (span < 604800) return n(86400) + " dagen geleden";
if (span < 2592000) return span / 604800 == 1 ? "1 week geleden" : n(604800) + " weken geleden";
if (span < 31536000) return span / 2592000 == 1 ? "1 maand geleden" : n(2592000) + " maanden geleden";
return n(31536000) + " jaar geleden";
}
// "ma 14 sep"; day_of_week 1 is Sunday, as ESPHome counts.
inline std::string date_text(int day_of_week, int day_of_month, int month) {
static const char *days[] = {"zo", "ma", "di", "wo", "do", "vr", "za"};
static const char *months[] = {"jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"};
if (day_of_week < 1 || day_of_week > 7 || month < 1 || month > 12) return "—";
return std::string(days[day_of_week - 1]) + " " + std::to_string(day_of_month) + " " + months[month - 1];
}
// What you see between icon and value, between two items and after the name, from the height of
// the digits; the editor's barGaps() uses the same factors.
struct Gaps { int icon, item, name; };
inline int scaled(int cap, int tenths) { return (cap * tenths + 5) / 10; }
inline Gaps gaps(int cap) {
return {std::max(2, scaled(cap, 4)), std::max(6, (cap * 125 + 50) / 100), std::max(8, scaled(cap, 16))};
}
// Right-aligned placement of item widths (ink, gaps included) in `width` pixels beside a name of
// `name_natural` pixels. The name keeps at least min(natural, 35% of the width); items leave from
// the front until the rest fits. `x` is each item's left ink edge; `name_room` what the name may use.
struct Placement {
size_t first = 0;
std::array<int, MAX_ITEMS> x{};
int name_room = 0;
};
inline Placement place(const int *widths, size_t count, const Gaps &g, int width, int name_natural) {
Placement p;
count = std::min(count, MAX_ITEMS);
int min_name = std::min(name_natural, width * 35 / 100);
auto total = [&](size_t from) {
int sum = 0;
for (size_t i = from; i < count; ++i) sum += widths[i] + (i > from ? g.item : 0);
return sum;
};
while (p.first < count && total(p.first) + g.name + min_name > width) ++p.first;
int x = width - total(p.first);
for (size_t i = p.first; i < count; ++i) { p.x[i] = x; x += widths[i] + g.item; }
p.name_room = p.first < count ? p.x[p.first] - g.name : width;
return p;
}
} // namespace header_bar
+219
View File
@@ -1,5 +1,6 @@
#pragma once
#include "runtime_model.h"
#include "header_bar.h"
#include "tile_palette.h"
#include "tile_icon.h"
#include "screen_settings.h"
@@ -33,6 +34,12 @@ inline const lv_font_t *clock_font = nullptr;
// Text in the -/+ pill and the run key of direct controls; the board profile sets it.
inline const lv_font_t *control_font = nullptr;
inline lv_obj_t *room_label = nullptr; // remembered by render() so page switches can render synchronously
// Top bar (0.2.32+): the profile's clock label only lends its place and margin; values use the
// profile's text font, icons its small icon font. Set by the board profile at boot.
inline header_bar::Bar header;
inline lv_obj_t *time_label = nullptr;
inline const lv_font_t *header_text_font = nullptr, *header_icon_font = nullptr;
inline void render_header();
inline std::function<esphome::ESPTime()> now_time;
inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast<uint32_t>(t.timestamp) : 0; }
inline void tick();
@@ -196,6 +203,33 @@ inline std::string receive(const std::string &payload) {
result = "Indeling ontvangen";
return true;
}
if (op == "header") {
// Validate every item before replacing the bar; an icon these fonts lack is left out.
if (!root["items"].is<JsonArray>()) return false;
header_bar::Bar next;
for (JsonVariant value : root["items"].as<JsonArray>()) {
if (next.count == header_bar::MAX_ITEMS || !value.is<JsonObject>()) return false;
header_bar::Item item;
item.kind = header_bar::kind(string(value["k"], 8));
if (item.kind == header_bar::Kind::none) return false;
uint32_t icon = tile_icon::codepoint(string(value["i"], 8));
item.icon = icon && has_icon_glyph(icon) ? icon : 0;
item.text = string(value["t"], header_bar::TEXT_BYTES);
item.epoch = value["e"].is<unsigned>() ? value["e"].as<uint32_t>() : 0;
item.has_color = header_bar::color(string(value["c"], 8), item.color);
if (item.kind == header_bar::Kind::ago && item.epoch <= 0) return false;
next.items[next.count++] = item;
}
next.received = true;
bool same = header.received && header.count == next.count;
for (size_t i = 0; same && i < next.count; ++i) same = header.items[i] == next.items[i];
header = next;
last_received = esphome::millis();
if (!same && refresh) refresh();
// The same status as a tile state, so a changing value never flips the inbox entity.
result = model.ready() ? "Gesynchroniseerd" : "Tegels laden";
return true;
}
if (op != "state" || !root["i"].is<unsigned>() || !root["a"].is<JsonObject>()) return false;
unsigned index = root["i"].as<unsigned>();
std::string entity = string(root["entity"], 120);
@@ -1288,6 +1322,7 @@ inline void render(lv_obj_t *room) {
if (!enabled) return;
room_label=room;
label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !ha_connected() ? "HA niet verbonden" : !feed_alive() ? "ESP Screens niet actief" : model.title);
render_header();
for (size_t slot = 0; slot < 6; ++slot) {
auto &w=widgets[slot];
if(!w.tile || w.index>=model.count)continue;
@@ -1427,6 +1462,190 @@ inline void render(lv_obj_t *room) {
}
}
// ---- Top bar ----
// Drawn in the board's pixels by the editor's rules (app.js barLayout): every value, the time too,
// in one font on the name's baseline; icons and the dial centred on the height of the digits; the
// gaps measured between glyph ink, so every icon sits equally close to its value.
struct HeaderSlot { lv_obj_t *icon{}, *text{}; uint32_t icon_color = 0; };
inline lv_obj_t *header_root = nullptr, *header_ring = nullptr;
inline std::array<lv_obj_t *, 2> header_hands{};
inline std::array<HeaderSlot, header_bar::MAX_ITEMS> header_slots{};
inline lv_point_precise_t header_points[4]{};
inline int header_dial_key = -1;
constexpr uint32_t HEADER_INK = 0x46525E;
inline void set_visible(lv_obj_t *obj, bool visible) {
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN) != visible) return;
if (visible) lv_obj_remove_flag(obj, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
}
// Ink edges of a text from its label's left edge, and its advance (what LVGL sizes a label by).
struct TextInk { int left = 0, right = 0, advance = 0; };
inline TextInk text_ink(const lv_font_t *font, const std::string &text) {
TextInk ink;
bool first = true;
size_t i = 0;
for (uint32_t cp = header_bar::next_codepoint(text, i); cp; cp = header_bar::next_codepoint(text, i)) {
lv_font_glyph_dsc_t g;
if (!lv_font_get_glyph_dsc(font, &g, cp, 0)) continue;
if (g.box_w > 0) {
if (first) { ink.left = ink.advance + g.ofs_x; first = false; }
ink.right = ink.advance + g.ofs_x + g.box_w;
}
ink.advance += g.adv_w;
}
return ink;
}
inline lv_obj_t *header_part(lv_obj_t *parent) {
auto *part = lv_label_create(parent);
lv_obj_remove_flag(part, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(part, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_style_text_color(part, lv_color_hex(HEADER_INK), 0);
return part;
}
// `live`: Home Assistant's values may show; without its link or the manager's feed only clocks stay.
inline void draw_header(bool live) {
if (!room_label || !time_label || !header_text_font || !header_icon_font) return;
set_visible(time_label, false);
auto *page = lv_obj_get_parent(room_label);
if (!header_root) {
header_root = lv_obj_create(page);
lv_obj_remove_style_all(header_root);
lv_obj_remove_flag(header_root, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(header_root, LV_OBJ_FLAG_SCROLLABLE);
// The clock label's place in the drawing order: under the tiles, cards and overlays.
lv_obj_move_to_index(header_root, lv_obj_get_index(time_label));
for (auto &slot : header_slots) {
slot.icon = header_part(header_root);
slot.text = header_part(header_root);
lv_obj_set_style_text_font(slot.icon, header_icon_font, 0);
lv_obj_set_style_text_font(slot.text, header_text_font, 0);
slot.icon_color = HEADER_INK;
}
header_ring = lv_obj_create(header_root);
lv_obj_remove_style_all(header_ring);
lv_obj_remove_flag(header_ring, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(header_ring, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_radius(header_ring, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_border_color(header_ring, lv_color_hex(HEADER_INK), 0);
lv_obj_set_style_border_opa(header_ring, LV_OPA_COVER, 0);
lv_obj_add_flag(header_ring, LV_OBJ_FLAG_HIDDEN);
for (auto *&hand : header_hands) {
hand = lv_line_create(header_root);
lv_obj_remove_flag(hand, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_line_rounded(hand, true, 0);
lv_obj_set_style_line_color(hand, lv_color_hex(HEADER_INK), 0);
lv_obj_add_flag(hand, LV_OBJ_FLAG_HIDDEN);
}
// A long name ends in dots instead of running under the items.
lv_label_set_long_mode(room_label, LV_LABEL_LONG_DOT);
}
// Geometry from the profile's own widgets: the name's margin and baseline, the clock's right margin.
const lv_font_t *name_font = lv_obj_get_style_text_font(room_label, LV_PART_MAIN);
int page_w = lv_obj_get_width(page), left = lv_obj_get_x(room_label);
// int32_t is long on the ESP32 toolchain: keep the arithmetic in int.
int width = std::max(0, page_w + static_cast<int>(lv_obj_get_style_x(time_label, LV_PART_MAIN)) - left);
int baseline = lv_obj_get_y(room_label) + (name_font->line_height - name_font->base_line);
lv_obj_set_pos(header_root, 0, 0);
lv_obj_set_size(header_root, page_w, baseline + name_font->line_height);
lv_font_glyph_dsc_t zero, dial_glyph;
if (!lv_font_get_glyph_dsc(header_text_font, &zero, '0', 0) || !zero.box_h) return;
// Twice the digits' ink centre keeps the halves exact.
int middle2 = 2 * (baseline - zero.ofs_y) - zero.box_h;
auto gaps = header_bar::gaps(zero.box_h);
// The dial is as large as a round icon (clock-outline) of the icon font.
int dial = lv_font_get_glyph_dsc(header_icon_font, &dial_glyph, 0xF0150, 0) && dial_glyph.box_h ? dial_glyph.box_h : zero.box_h * 3 / 2;
// The manager's items; until it sends them, the clock of show_clock as before the top bar.
header_bar::Bar fallback;
if (!header.received && screen_settings::current.show_clock) { fallback.items[0].kind = header_bar::Kind::clock; fallback.count = 1; }
const header_bar::Bar &bar = header.received ? header : fallback;
auto now = now_time ? now_time() : esphome::ESPTime{};
struct Part { size_t item = 0; uint32_t icon = 0; int icon_left = 0, icon_w = 0, text_left = 0, text_w = 0, width = 0; bool dial = false; std::string text; };
std::array<Part, header_bar::MAX_ITEMS> parts;
std::array<int, header_bar::MAX_ITEMS> widths{};
size_t count = 0;
for (size_t i = 0; i < bar.count; ++i) {
const auto &item = bar.items[i];
using header_bar::Kind;
if ((item.kind == Kind::text || item.kind == Kind::ago) && !live) continue;
Part p;
p.item = i;
if (item.kind == Kind::analog) { p.dial = true; p.width = dial; }
else {
p.text = item.kind == Kind::clock ? (now.is_valid() ? now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M") : std::string("--:--"))
: item.kind == Kind::date ? (now.is_valid() ? header_bar::date_text(now.day_of_week, now.day_of_month, now.month) : std::string("—"))
: item.kind == Kind::ago ? header_bar::ago_text(item.epoch, now_epoch()) : item.text;
lv_font_glyph_dsc_t g;
if (item.icon && lv_font_get_glyph_dsc(header_icon_font, &g, item.icon, 0) && g.box_w) { p.icon = item.icon; p.icon_left = g.ofs_x; p.icon_w = g.box_w; }
auto ink = text_ink(header_text_font, p.text);
p.text_left = ink.left;
p.text_w = std::max(0, ink.right - ink.left);
p.width = p.icon_w + (p.icon && p.text_w ? gaps.icon : 0) + p.text_w;
}
if (!p.width) continue;
widths[count] = p.width;
parts[count++] = p;
}
lv_point_t name_size;
lv_text_get_size(&name_size, lv_label_get_text(room_label), name_font, 0, 0, LV_COORD_MAX, LV_TEXT_FLAG_EXPAND);
auto placement = header_bar::place(widths.data(), count, gaps, width, name_size.x);
lv_obj_set_width(room_label, std::max(1, std::min<int>(name_size.x, placement.name_room)));
lv_obj_set_height(room_label, lv_font_get_line_height(name_font));
std::array<bool, header_bar::MAX_ITEMS> icon_on{}, text_on{};
bool dial_on = false;
for (size_t k = placement.first; k < count; ++k) {
const auto &p = parts[k];
auto &slot = header_slots[k];
int x = left + placement.x[k];
if (p.dial) {
int top = (middle2 - dial) / 2, stroke = std::max(1, (dial + 5) / 10), hand = std::max(1, (dial * 75 + 500) / 1000);
lv_obj_set_pos(header_ring, x, top);
lv_obj_set_size(header_ring, dial, dial);
if (lv_obj_get_style_border_width(header_ring, LV_PART_MAIN) != stroke) lv_obj_set_style_border_width(header_ring, stroke, 0);
int minute = now.is_valid() ? now.hour * 60 + now.minute : 0;
int key = ((minute * 1024 + x) * 1024 + top) * 64 + dial;
if (key != header_dial_key) {
header_dial_key = key;
float centre = dial / 2.0f, hour_angle = (minute % 720) * 3.14159265f / 360, minute_angle = (minute % 60) * 3.14159265f / 30;
header_points[0] = header_points[2] = {(lv_value_precise_t)centre, (lv_value_precise_t)centre};
header_points[1] = {(lv_value_precise_t)(centre + 0.24f * dial * sinf(hour_angle)), (lv_value_precise_t)(centre - 0.24f * dial * cosf(hour_angle))};
header_points[3] = {(lv_value_precise_t)(centre + 0.34f * dial * sinf(minute_angle)), (lv_value_precise_t)(centre - 0.34f * dial * cosf(minute_angle))};
for (size_t h = 0; h < header_hands.size(); ++h) {
lv_line_set_points(header_hands[h], header_points + 2 * h, 2);
lv_obj_set_pos(header_hands[h], x, top);
set_line_width(header_hands[h], hand);
}
}
dial_on = true;
continue;
}
if (p.icon) {
lv_font_glyph_dsc_t g;
lv_font_get_glyph_dsc(header_icon_font, &g, p.icon, 0);
label(slot.icon, tile_icon::utf8(p.icon));
// LVGL draws a glyph's ink from (line_height - base_line) - box_h - ofs_y below the label top.
int ink_top = (middle2 - g.box_h) / 2;
lv_obj_set_pos(slot.icon, x - g.ofs_x, ink_top - ((header_icon_font->line_height - header_icon_font->base_line) - g.box_h - g.ofs_y));
const auto &item = bar.items[p.item];
uint32_t color = item.has_color ? item.color : HEADER_INK;
if (slot.icon_color != color) { lv_obj_set_style_text_color(slot.icon, lv_color_hex(color), 0); slot.icon_color = color; }
icon_on[k] = true;
x += p.icon_w + (p.text_w ? gaps.icon : 0);
}
if (p.text_w) {
label(slot.text, p.text);
lv_obj_set_pos(slot.text, x - p.text_left, baseline - (header_text_font->line_height - header_text_font->base_line));
text_on[k] = true;
}
}
for (size_t k = 0; k < header_slots.size(); ++k) { set_visible(header_slots[k].icon, icon_on[k]); set_visible(header_slots[k].text, text_on[k]); }
set_visible(header_ring, dial_on);
for (auto *hand : header_hands) set_visible(hand, dial_on);
}
inline void render_header() {
if (enabled) draw_header(ha_connected() && feed_alive());
}
// Inspect actual LVGL coordinates, including padding and the loaded font metrics.
inline bool check_tile_geometry() {
bool ok=true;
+35
View File
@@ -177,6 +177,41 @@ staan iconen in de browser uit het midden); draai daarna `generate_packages.py`.
De substituties `MDI_GLYPH_*` zijn vervallen: `TILEn_ICON` in handmatige
profielen moet uit de set komen. Geen gewijzigde preferences of sleutels.
### Compatibiliteit 0.2.38 / firmware 0.2.32
Opslagversie en tegelprotocol blijven 1. Een indeling mag een optioneel object
`header: {items: [...]}` hebben (maximaal zes): `{type: clock|analog|date}` of
`{type: entity, entity, content: state|last_changed, icon: auto|none|<naam>, show:
always|active}`. Zonder `header` geldt wat de firmware altijd deed (de klok van
`show_clock`); een oude editor die `header` weglaat behoudt de opgeslagen balk. Bij
opslaan volgt `settings.show_clock` de klok in de balk, zodat oudere firmware de tijd
alleen toont als hij erin staat. Entiteiten in de balk mogen naast de tegeldomeinen
ook `device_tracker`, `zone`, `lock`, `alarm_control_panel`, `counter`, `event`,
`input_datetime`, `input_text`, `water_heater` en `humidifier` zijn; de inventaris
markeert die met `tile: false` zodat de tegelkiezer ze overslaat.
Op de draad stuurt de manager direct na het layoutbericht `{v:1, op: header, items}`
met per onderdeel `k` (`clock`, `analog`, `date`, `text`, `ago`), `i` (codepoint),
`t` (tekst, al in het Nederlands opgemaakt en beperkt tot de glyphs van
`sublabel_big`), `e` (unix-tijd voor `ago`) en `c` (accentkleur). Onderdelen met
`show: active` die niet actief zijn stuurt de manager niet. Alleen firmware 0.2.32+
krijgt dit bericht (`HEADER_MIN_FIRMWARE`, op basis van `Schermfirmware`): oudere
firmware zou `op: header` weigeren met "Fout: ongeldig bericht". Het bericht wordt
apart vergeleken; een gewijzigde waarde verstuurt alleen de balk, niet de indeling.
De firmware antwoordt met dezelfde status als een tegelstatus, dus de
Tegelinstellingen-entiteit flipt niet bij iedere waarde.
Firmware: `header_bar.h` (LVGL-vrij: parsen, "5 min geleden", datum, afstanden,
plaatsing) en `runtime_tiles::draw_header()`. Het tijdlabel van het profiel blijft
bestaan als referentie (rechtermarge, tekenvolgorde) maar is in runtime-modus
verborgen. Waarden gebruiken `sublabel_big`, iconen `materialdesign_icons_mini`;
`sublabel_big` kreeg `'`, `#`, `*`, `=`, `;`, `²`, `³`, `µ`, `–` en veelvoorkomende
accenten; `tests/test_header_bar.py` houdt `header_bar.GLYPHS` gelijk aan die lijst.
De editor (`barLayout` in `app.js`) en de firmware (`header_bar::gaps/place`) delen
dezelfde gehele rekensommen; de test controleert dat. `tools/render_topbar.py`
rendert de echte `draw_header()` op een Mac/Linux-machine (ESPHome host + SDL2) naar
PNG, zonder scherm. Geen gewijzigde preferences of sleutels.
### Compatibiliteit 0.2.35 / firmware 0.2.30
Alleen firmware (Guition). `on_boot` haalt `LV_OBJ_FLAG_CLICKABLE` van `home_page` en
+65
View File
@@ -0,0 +1,65 @@
# Testresultaten app 0.2.38 / firmware 0.2.32 (2026-09-14)
Nieuw: de **bovenbalk** per scherm. Links de naam, rechts tot zes onderdelen: tijd,
analoge klok, datum of een entiteit met icoon (status of "laatst gewijzigd", altijd of
alleen als actief). Zie README "Bovenbalk" en RELEASING "Compatibiliteit 0.2.38".
## Wat veranderde
- App: `core.validate_header` (opslag), `header_bar.py` (Nederlandse opmaak met
weergaveprecisie uit het entity-register, iconen per device class en stand,
HA-kleuren, "alleen als actief", suggesties per ruimte), `op: header` direct na de
indeling voor firmware 0.2.32+, `POST /api/header-preview` voor de editor,
bovenbalk-entiteiten tellen mee voor statuswijzigingen.
- Editor: blok **Bovenbalk** (naam, kaartjes, slepen, sheet voor toevoegen en
instellen), de balk op elke pagina van het schermvoorbeeld als SVG met Roboto
(`bar-roboto-400/500.woff`, gegenereerd door `tools/generate_icons.py`) en de
plaatsingsregels van de firmware; knoppenbalk en **Klok tonen** weg.
- Firmware: `header_bar.h` (parsen, relatieve tijd, datum, afstanden, plaatsing) en
`runtime_tiles::draw_header()`; beide profielen zetten de fonts en het tijdlabel als
referentie; `sublabel_big` kreeg extra glyphs.
## Geautomatiseerd
- Python (`.venv-portal`): 121 tests OK, waarvan 18 nieuw in `test_header_bar.py`
(validatie, getallen en eenheden, waarden per domein, actief/kleur/icoon, glyphs gelijk
aan beide profielen en pakketten, woorden en rekensommen gelijk in editor en firmware,
profielkoppeling, manager: alleen 0.2.32+ krijgt de balk, geen herhaling bij gelijke
balk, oude editor behoudt de balk, verdwenen entiteit geweigerd, preview-endpoint).
- C++ (clang++ -std=c++17 -Wall -Wextra -Werror -I.): 13/13 PASS; nieuw
`test_header_bar` (soorten, kleuren, UTF-8, alle drempels van "geleden"/"over",
datum, afstanden voor Guition en CYD, plaatsing met overloop en lege balk).
- `generate_packages.py --check` en `generate_icons.py --check` groen; bench-kopieën
`.esphome/easy-*-package.yaml` opnieuw gegenereerd; `node --check app.js` OK.
- ESPHome 2026.6.2 compile: `easy-guition-device.yaml` SUCCESS (RAM 32,4%, flash
18,6%; was 32,3% / 18,5%), `easy-cyd-device.yaml` SUCCESS (RAM 31,4%, flash 77,3%;
was 31,3% / 76,8%). Eén bestaande waarschuwing (`runtime_tiles.h:580`,
enum-conversie), niet uit deze wijziging. De eerste Guition-build faalde op
`std::max(int, long)` (int32_t is long op ESP32); opgelost met een cast.
- **Echte LVGL-render zonder scherm:** `tools/render_topbar.py` bouwt een ESPHome
host-project met SDL2 en de fonts uit beide profielen en tekent zeven scenario's via
`draw_header()` (alleen klok, de mockup met "—" en "Gisteren", deur open + alarm,
analoge klok + datum, aantal thuis + weer + verbruik, te vol met afgekorte naam,
personen). Bekeken op 2x/3x: waarden op de basislijn van de naam, iconen op
cijferhoogte, gelijke afstanden, overloop valt vooraan weg.
- Editor in de browser (dev-server met een alleen-lezen momentopname van de echte HA):
toevoegen via suggesties en zoeken, instellen (status/laatst gewijzigd, icoon, tonen),
slepen, overloop- en verborgen markering, opslaan stuurt `layout`, `header`, states
naar een 0.2.32-scherm en geen `header` naar 0.2.31; CYD-maten en de firmwarehint op
een CYD; telefoonbreedte zonder horizontaal scrollen.
## Niet getest
- Geen scherm aan USB tijdens deze ronde: de bovenbalk is niet op glas gezien. De render
gebruikt dezelfde LVGL 9.5-code en fonts, maar niet het paneel (kleuren, backlight).
- Studio 1 draait nog firmware 0.2.31 en app 0.2.37 tot de update.
## Te controleren op Studio 1 na de update
- Add-on bijwerken naar 0.2.38, dan **Bijwerken** bij Studio 1 (firmware 0.2.32).
- Zonder bovenbalk-aanpassing: naam links, tijd rechts in de nieuwe waardeletter.
- Voeg temperatuur, een persoon met "Laatst gewijzigd", een deur of alarm (alleen als
actief) en de analoge klok toe; kijk of alles op één lijn staat en even ver uit elkaar.
- "5 min geleden" telt per minuut door; een deur die opengaat verschijnt binnen enkele
seconden in amber; een lange naam krijgt puntjes zoals in de editor.
- HA even loskoppelen: de entiteiten verdwijnen uit de balk, tijd en datum blijven.
+13 -8
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.31"
SCREEN_FIRMWARE_VERSION: "0.2.32"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -644,6 +644,11 @@ esphome:
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
// label lends its margin and drawing order and stays hidden in runtime mode.
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::time_label = id(lbl_time);
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
runtime_tiles::rotation_supported = true;
@@ -1155,7 +1160,7 @@ font:
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·']
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–']
# Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -5216,8 +5221,11 @@ script:
id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300);
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
// Runtime mode draws the clock in the top bar; the manual profile keeps its own label.
if (!runtime_tiles::enabled) {
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
}
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
- id: close_cards
@@ -6356,11 +6364,8 @@ script:
- delay: 100ms
- lambda: |-
if (runtime_tiles::enabled) {
// The name and the top bar (clock included) draw together.
runtime_tiles::render(id(lbl_room));
auto now = id(homeassistant_time).now();
if (screen_settings::current.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
if (now.is_valid()) runtime_tiles::label(id(lbl_time), now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M"));
return;
}
auto set_lbl = [](lv_obj_t* lbl, const std::string& s) {
+13 -8
View File
@@ -58,7 +58,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.31"
SCREEN_FIRMWARE_VERSION: "0.2.32"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -827,6 +827,11 @@ esphome:
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
// label lends its margin and drawing order and stays hidden in runtime mode.
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::time_label = id(lbl_time);
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings();
@@ -1345,7 +1350,7 @@ font:
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·']
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–']
# Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -5316,8 +5321,11 @@ script:
id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
// Runtime mode draws the clock in the top bar; the manual profile keeps its own label.
if (!runtime_tiles::enabled) {
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
}
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
- id: close_cards
@@ -6452,11 +6460,8 @@ script:
- delay: 100ms
- lambda: |-
if (runtime_tiles::enabled) {
// The name and the top bar (clock included) draw together.
runtime_tiles::render(id(lbl_room));
auto now = id(homeassistant_time).now();
if (screen_settings::current.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
if (now.is_valid()) runtime_tiles::label(id(lbl_time), now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M"));
return;
}
auto set_lbl = [](lv_obj_t* lbl, const std::string& s) {
+13 -8
View File
@@ -59,7 +59,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.31"
SCREEN_FIRMWARE_VERSION: "0.2.32"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -831,6 +831,11 @@ esphome:
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
// label lends its margin and drawing order and stays hidden in runtime mode.
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::time_label = id(lbl_time);
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
runtime_tiles::load_settings();
@@ -1334,7 +1339,7 @@ font:
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·']
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–']
# Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -5247,8 +5252,11 @@ script:
id(back_light).turn_on().set_transition_length(300).set_brightness(level / 100.0f).perform();
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
// Runtime mode draws the clock in the top bar; the manual profile keeps its own label.
if (!runtime_tiles::enabled) {
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
}
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
- id: close_cards
@@ -6383,11 +6391,8 @@ script:
- delay: 100ms
- lambda: |-
if (runtime_tiles::enabled) {
// The name and the top bar (clock included) draw together.
runtime_tiles::render(id(lbl_room));
auto now = id(homeassistant_time).now();
if (screen_settings::current.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
if (now.is_valid()) runtime_tiles::label(id(lbl_time), now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M"));
return;
}
auto set_lbl = [](lv_obj_t* lbl, const std::string& s) {
+13 -8
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.31"
SCREEN_FIRMWARE_VERSION: "0.2.32"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -648,6 +648,11 @@ esphome:
runtime_tiles::now_time = []() { return id(homeassistant_time).now(); };
runtime_tiles::mini_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::control_font = id(sublabel_big)->get_lv_font();
// Top bar (0.2.32+): values in the text font, icons in the small icon font; the clock
// label lends its margin and drawing order and stays hidden in runtime mode.
runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();
runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();
runtime_tiles::time_label = id(lbl_time);
runtime_tiles::enabled = std::string("${DYNAMIC_TILES}") == "true";
if (!runtime_tiles::enabled) return;
runtime_tiles::rotation_supported = true;
@@ -1144,7 +1149,7 @@ font:
'0','1','2','3','4','5','6','7','8','9',
'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
' ','a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z',
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·']
'ä','ö','ü','Ä','Ö','Ü','ß','/','…','·',"'",'#','*','=',';','²','³','µ','é','è','ë','ê','ï','î','ó','ô','à','á','â','ç','ñ','ú','û','–']
# Material Design Icons (MDI) — included at fonts/materialdesignicons-webfont.ttf (Apache 2.0)
# Source: https://github.com/Templarian/MaterialDesign-Webfont
@@ -5147,8 +5152,11 @@ script:
id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300);
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
// Runtime mode draws the clock in the top bar; the manual profile keeps its own label.
if (!runtime_tiles::enabled) {
if (settings.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
}
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
- id: close_cards
@@ -6287,11 +6295,8 @@ script:
- delay: 100ms
- lambda: |-
if (runtime_tiles::enabled) {
// The name and the top bar (clock included) draw together.
runtime_tiles::render(id(lbl_room));
auto now = id(homeassistant_time).now();
if (screen_settings::current.show_clock) lv_obj_clear_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(id(lbl_time), LV_OBJ_FLAG_HIDDEN);
if (now.is_valid()) runtime_tiles::label(id(lbl_time), now.strftime(screen_settings::current.clock_24h ? "%H:%M" : "%I:%M"));
return;
}
auto set_lbl = [](lv_obj_t* lbl, const std::string& s) {
+12
View File
@@ -1,3 +1,15 @@
## 0.2.38 (firmware 0.2.32)
- **Bovenbalk per scherm.** Bovenaan elk scherm staat links de naam en rechts wat jij kiest, tot zes onderdelen: de **tijd**, een **analoge klok**, de **datum**, of een **entiteit met icoon** uit Home Assistant, zoals temperatuur, luchtvochtigheid, verbruik, een deur of raam (open/dicht), het alarmpaneel, een slot, wie er thuis is (`zone.home`) of een telefoon (`device_tracker`). Zonder aanpassing blijft het zoals het was: naam en tijd.
- **Status of "Laatst gewijzigd".** Een onderdeel toont de status zoals Home Assistant hem schrijft (21,3 °C, 65%, 1.249 W, Open/Dicht, Thuis/Weg, Afwezig, de volgende zonsondergang), of hoe lang geleden het veranderde: "Zojuist", "5 min geleden", "Gisteren". Een tijdstempel-sensor telt ook vooruit ("Over 2 uur"). Het scherm telt de minuten zelf, zonder verkeer.
- **Alleen als actief.** Laat een onderdeel alleen verschijnen als het aan, open, thuis of meer dan 0 is, bijvoorbeeld een open deur of een draaiende wasmachine. Actieve onderdelen kleuren zoals in Home Assistant: open deur amber, alarm ingeschakeld groen, alarm afgegaan of slot open rood, iemand thuis groen.
- **Iconen.** Automatisch zoals Home Assistant (een open deur krijgt een open deur-icoon, een temperatuursensor een thermometer), een eigen icoon uit de lijst, of geen icoon.
- **Netjes op het oog.** Alle waarden, de tijd ook, staan in dezelfde letter op de basislijn van de naam; iconen en de analoge klok staan gecentreerd op de cijferhoogte en even groot; de ruimte tussen icoon en waarde wordt gemeten tussen wat je ziet, dus voor elk icoon gelijk. Past niet alles, dan krijgt de naam puntjes en valt het voorste onderdeel weg.
- **Editor.** Nieuw blok **Bovenbalk** met de naam en de onderdelen als kaartjes: slepen om te ordenen, tikken om in te stellen, **+ Toevoegen** met de tijd, klok en datum, suggesties uit je eigen huis (temperatuur en verbruik uit de ruimte van het scherm, weer, aantal thuis, zon op en onder) en een zoekveld voor elke entiteit. Elke pagina in het schermvoorbeeld toont de balk met Roboto en dezelfde plaatsingsregels als het scherm; onderdelen die niet passen of nu verborgen zijn, zijn gemarkeerd.
- De knoppenbalk **Tegels instellen / Algemene instellingen / Inspector** is weg; de secties staan gewoon onder elkaar. **Klok tonen** is verhuisd naar de bovenbalk (de 24-uursklok blijft bij de scherminstellingen en staat ook bij de klok in de balk).
- Firmware 0.2.32 voor beide borden; de app biedt de update aan. Oudere firmware toont tot de update de naam en, als die in de balk staat, de tijd.
- Voor ontwikkelaars: `tools/render_topbar.py` rendert de echte LVGL-code van de bovenbalk op je computer (ESPHome host + SDL2) naar PNG, voor beide borden, zonder scherm.
## 0.2.37 (firmware 0.2.31)
- **Cheatsheet Alerts in ESP Screens.** De knop **Alerts** bovenaan opent een naslagpagina met alles over `show_alert`: per scherm de exacte actienaam (`esphome.<apparaatnaam>_show_alert`) met kopieerknop en of de firmware het al kan, een kant-en-klaar YAML-voorbeeld per scherm, alle zeven velden met uitleg, voorbeelden en de tekstlimieten per bord, alle iconen met glyph en naam (zoeken, tikken kopieert de naam), de kleuren als stalen, het gedrag (timeout, knop, knipperen, standby, vervangen) en het event `esphome.screen_alert` met een voorbeeldautomatisering die op de knop wacht. Home Assistant toont bij ESPHome-acties zelf geen uitleg of keuzelijsten; deze pagina vult dat gat.
+5
View File
@@ -12,6 +12,11 @@ voor bestaande profielen: controleren, bouwen of opnieuw installeren via USB of
het IP-adres (OTA). Bestaande ESPHome-profielen in de HA-configmap worden
automatisch gevonden.
Bovenaan de editor stel je per scherm de **Bovenbalk** in: links de naam, rechts
tot zes onderdelen (tijd, analoge klok, datum of een entiteit met icoon, zoals
temperatuur, een deur, het alarm of "laatst gewijzigd"). Sleep om te ordenen, tik
om in te stellen; firmware 0.2.32 of nieuwer tekent ze.
Na koppeling via de HA ESPHome-integratie kies je in ESP Screens de tegels.
Onder **Tegelinstellingen** staan klikgedrag, grotere waarden en mini-schuiven.
Onder **Scherminstellingen** staan helderheid en standby. **Inspector** helpt bij
+65 -5
View File
@@ -16,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.31'
FIRMWARE_VERSION = '0.2.32'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action 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 is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -149,6 +149,59 @@ def entity_id(value):
return False
return value in BUILTIN if value.startswith('screen.') else value.split('.')[0] in DOMAINS
# ----- Top bar (firmware 0.2.32+): the screen name on the left, up to six items on the right.
# Without a `header` the screen keeps its name and the clock of `show_clock`. -----
HEADER_MIN_FIRMWARE = (0, 2, 32)
HEADER_MAX_ITEMS = 6
# Items the screen draws on its own clock, without Home Assistant.
HEADER_BUILTIN = {'clock': 'Tijd', 'analog': 'Analoge klok', 'date': 'Datum'}
# Only shown, never controlled: the top bar takes these besides every tile domain.
HEADER_ONLY_DOMAINS = frozenset('device_tracker zone lock alarm_control_panel counter event input_datetime input_text water_heater humidifier'.split())
HEADER_CONTENTS = ('state', 'last_changed')
HEADER_SHOWS = ('always', 'active')
def header_entity(value):
return (isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value) is not None
and value.split('.')[0] in (DOMAINS - {'screen'}) | HEADER_ONLY_DOMAINS)
def header_items(layout):
"""Items the top bar shows: the stored ones, else what firmware before the top bar drew (the clock)."""
if 'header' in layout:
return layout['header']['items']
return [{'type': 'clock'}] if layout.get('settings', {}).get('show_clock', True) else []
def validate_header(data):
if not isinstance(data, dict) or set(data) - {'items'} or not isinstance(data.get('items'), list):
raise ValueError('Ongeldige bovenbalk; vernieuw de beheerpagina.')
if len(data['items']) > HEADER_MAX_ITEMS:
raise ValueError(f'De bovenbalk heeft plaats voor maximaal {HEADER_MAX_ITEMS} onderdelen.')
items, seen = [], set()
for item in data['items']:
kind = item.get('type') if isinstance(item, dict) else None
if kind in HEADER_BUILTIN:
if set(item) != {'type'}:
raise ValueError('Ongeldige instelling in de bovenbalk.')
clean = {'type': kind}
elif kind == 'entity':
if set(item) - {'type', 'entity', 'content', 'icon', 'show'}:
raise ValueError('Onbekende instelling in de bovenbalk; vernieuw de beheerpagina.')
if not header_entity(item.get('entity')):
raise ValueError('Deze entiteit kan niet in de bovenbalk.')
clean = {'type': 'entity', 'entity': item['entity'], 'content': item.get('content', 'state'),
'icon': item.get('icon', 'auto'), 'show': item.get('show', 'always')}
if clean['content'] not in HEADER_CONTENTS or clean['show'] not in HEADER_SHOWS:
raise ValueError('Ongeldige instelling in de bovenbalk.')
if not (clean['icon'] in ('auto', 'none') or isinstance(clean['icon'], str) and clean['icon'] in tile_icons.ICONS):
raise ValueError('Kies een icoon uit de lijst.')
else:
raise ValueError('Onbekend onderdeel in de bovenbalk; vernieuw de beheerpagina.')
key = json.dumps(clean, sort_keys=True)
if key in seen:
raise ValueError('Dit onderdeel staat al in de bovenbalk.')
seen.add(key)
items.append(clean)
return {'items': items}
def min_firmware(layout):
"""Oldest firmware that still accepts this layout; None when any version works."""
if any(t.get('options', {}).get('background') == 'none' for t in layout['tiles']):
@@ -238,6 +291,8 @@ def validate_layout(data):
result['pages'] = data['pages']
if 'settings' in data:
result['settings'] = validate_settings(data['settings'])
if 'header' in data:
result['header'] = validate_header(data['header'])
return result
def local_clock(value, tz):
@@ -461,17 +516,22 @@ def discover(registry, states, devices, areas):
'device': device.get('name') or '',
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
'status': state.get('state', 'Niet verbonden')})
if not entity_id(eid) or item.get('disabled_by'):
tile = entity_id(eid)
if not (tile or header_entity(eid)) or item.get('disabled_by'):
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'), 'icon': tile_icons.ha_icon(state.get('attributes'))})
'state': state.get('state', 'unavailable'), 'icon': tile_icons.ha_icon(state.get('attributes')),
# Top-bar-only domains (a phone's tracker, a lock) stay out of the tile picker.
**({} if tile else {'tile': False})})
# YAML entities may not have an entity-registry entry.
registered = {e['id'] for e in entities}
in_registry = {r['entity_id'] for r in registry}
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):
tile = entity_id(eid)
if (tile or header_entity(eid)) and eid not in registered and eid not in in_registry:
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'))})
'icon': tile_icons.ha_icon(state.get('attributes')), **({} if tile else {'tile': False})})
return screens, sorted(entities, key=lambda e: e['name'].casefold())
def installation_yaml(data):
+342
View File
@@ -0,0 +1,342 @@
"""Top bar of a screen ("bovenbalk"): what the firmware draws right of the screen name.
Firmware 0.2.32+ only draws. This module decides everything that needs Home Assistant: an
entity's text in Dutch (number format, units, translated states), its icon, its accent colour
and whether it shows at all. Clock, analog clock and date tick on the screen itself, and a
relative time ("5 min geleden") travels as a unix time, so the screen counts the minutes
without any traffic.
Wire format, one message after the layout: {"v":1,"op":"header","items":[...]} with per item
"k" (clock, analog, date, text, ago), "i" (icon codepoint), "t" (text), "e" (unix time) and
"c" (accent colour RRGGBB).
"""
import math
import unicodedata
from datetime import datetime, time
import tile_icons
from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_FIRMWARE, HEADER_SHOWS, epoch, header_items, local_clock, short
# Characters the top bar's text font carries on both boards (`sublabel_big` in the profiles;
# tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes.
GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–")
TEXT_BYTES = 40
MONTHS = ('jan', 'feb', 'mrt', 'apr', 'mei', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec')
UNAVAILABLE = '—'
CONTENT_LABELS = {'state': 'Status', 'last_changed': 'Laatst gewijzigd'}
SHOW_LABELS = {'always': 'Altijd', 'active': 'Alleen als actief'}
# Home Assistant's own words where they fit a small bar; the screen shows the rest as they come.
STATES = {
'on': 'Aan', 'off': 'Uit', 'home': 'Thuis', 'not_home': 'Weg', 'open': 'Open', 'closed': 'Dicht',
'opening': 'Gaat open', 'closing': 'Gaat dicht', 'locked': 'Op slot', 'unlocked': 'Open', 'locking': 'Sluiten',
'unlocking': 'Openen', 'jammed': 'Vast', 'playing': 'Speelt', 'paused': 'Pauze', 'idle': 'Inactief',
'standby': 'Stand-by', 'buffering': 'Laden', 'cleaning': 'Bezig', 'docked': 'In dock', 'returning': 'Naar dock',
'error': 'Fout', 'heat': 'Verwarmen', 'cool': 'Koelen', 'heat_cool': 'Auto', 'auto': 'Auto', 'dry': 'Drogen',
'fan_only': 'Ventileren', 'disarmed': 'Uit', 'armed_home': 'Thuis', 'armed_away': 'Afwezig', 'armed_night': 'Nacht',
'armed_vacation': 'Vakantie', 'armed_custom_bypass': 'Aan', 'arming': 'Inschakelen', 'pending': 'Wachten',
'triggered': 'Alarm', 'active': 'Loopt', 'above_horizon': 'Op', 'below_horizon': 'Onder',
}
# device_class: (on, off)
BINARY_STATES = {
'battery': ('Laag', 'Normaal'), 'battery_charging': ('Laden', 'Niet laden'), 'carbon_monoxide': ('Gevaar', 'Veilig'),
'cold': ('Koud', 'Normaal'), 'connectivity': ('Verbonden', 'Verbroken'), 'door': ('Open', 'Dicht'),
'garage_door': ('Open', 'Dicht'), 'gas': ('Gevaar', 'Veilig'), 'heat': ('Heet', 'Normaal'), 'light': ('Licht', 'Donker'),
'lock': ('Open', 'Op slot'), 'moisture': ('Nat', 'Droog'), 'motion': ('Beweging', 'Geen beweging'),
'moving': ('Beweegt', 'Stil'), 'occupancy': ('Bezet', 'Vrij'), 'opening': ('Open', 'Dicht'), 'plug': ('Aangesloten', 'Los'),
'power': ('Aan', 'Uit'), 'presence': ('Thuis', 'Weg'), 'problem': ('Probleem', 'OK'), 'running': ('Actief', 'Inactief'),
'safety': ('Onveilig', 'Veilig'), 'smoke': ('Rook', 'Geen rook'), 'sound': ('Geluid', 'Stil'), 'tamper': ('Sabotage', 'OK'),
'update': ('Update', 'Bijgewerkt'), 'vibration': ('Trilling', 'Stil'), 'window': ('Open', 'Dicht'),
}
# Icon names from tile_icons; the fonts of both boards carry every one of them.
BINARY_ICONS = { # device_class: (on, off)
'motion': ('motion-sensor', 'motion-sensor'), 'occupancy': ('home-account', 'home-account'),
'presence': ('home-account', 'home-account'), 'door': ('door-open', 'door-closed'), 'garage_door': ('garage', 'garage'),
'opening': ('door-open', 'door-closed'), 'window': ('window-closed-variant', 'window-closed-variant'),
'connectivity': ('router-wireless', 'router-wireless'), 'plug': ('power-plug', 'power-plug'), 'power': ('flash', 'flash'),
'moisture': ('water-alert', 'water'), 'smoke': ('smoke-detector', 'smoke-detector'), 'gas': ('meter-gas', 'meter-gas'),
'carbon_monoxide': ('smoke-detector', 'smoke-detector'), 'lock': ('lock-open-variant', 'lock'),
'battery': ('battery-high', 'battery-high'), 'battery_charging': ('battery-high', 'battery-high'),
'problem': ('alert-outline', 'check'), 'safety': ('alert-outline', 'check'), 'tamper': ('alert-outline', 'check'),
'vibration': ('alert-outline', 'check'), 'heat': ('fire', 'fire'), 'cold': ('snowflake', 'snowflake'),
'light': ('weather-sunny', 'weather-night'), 'running': ('play', 'stop'), 'sound': ('music-note', 'music-note'),
'update': ('cog', 'cog'), 'moving': ('run', 'run'),
}
SENSOR_ICONS = {
'temperature': 'thermometer', 'humidity': 'water-percent', 'moisture': 'water-percent', 'power': 'flash', 'energy': 'flash',
'apparent_power': 'flash', 'reactive_power': 'flash', 'voltage': 'flash', 'current': 'flash', 'power_factor': 'flash',
'battery': 'battery-high', 'illuminance': 'weather-sunny', 'pressure': 'gauge', 'atmospheric_pressure': 'gauge',
'carbon_dioxide': 'molecule-co2', 'gas': 'meter-gas', 'water': 'water', 'signal_strength': 'router-wireless',
'timestamp': 'clock-outline', 'duration': 'timer-outline', 'pm1': 'air-purifier', 'pm25': 'air-purifier',
'pm10': 'air-purifier', 'aqi': 'air-purifier', 'volatile_organic_compounds': 'air-purifier', 'wind_speed': 'weather-windy',
'speed': 'weather-windy', 'precipitation': 'weather-rainy', 'precipitation_intensity': 'weather-rainy',
'irradiance': 'solar-power', 'date': 'calendar',
}
DOMAIN_ICONS = {
'light': 'lightbulb', 'switch': 'toggle-switch', 'input_boolean': 'toggle-switch', 'fan': 'fan', 'cover': 'window-shutter',
'climate': 'thermostat', 'vacuum': 'robot-vacuum', 'media_player': 'speaker', 'scene': 'palette', 'script': 'script-text',
'button': 'gesture-tap-button', 'input_button': 'gesture-tap-button', 'person': 'account', 'device_tracker': 'map-marker',
'zone': 'account-group', 'lock': 'lock', 'alarm_control_panel': 'shield-home', 'timer': 'timer-outline', 'counter': 'gauge',
'event': 'bell-ring', 'input_datetime': 'calendar', 'input_text': 'script-text', 'water_heater': 'water-boiler',
'humidifier': 'air-purifier', 'select': 'cog', 'input_select': 'cog', 'number': 'gauge', 'input_number': 'gauge',
'sensor': 'gauge', 'binary_sensor': 'gauge',
}
# Their state is the moment they last ran or fired: the bar shows how long ago.
MOMENT_DOMAINS = frozenset(['scene', 'button', 'input_button', 'event'])
INACTIVE = frozenset(['off', 'closed', 'not_home', 'idle', 'docked', 'standby', 'locked', 'disarmed', 'paused', 'below_horizon'])
AMBER, GREEN, RED, BLUE, ORANGE, TEAL = 'FFB300', '43A047', 'E53935', '2196F3', 'FF6F22', '009688'
ALARM_CLASSES = frozenset(['problem', 'safety', 'smoke', 'gas', 'carbon_monoxide', 'moisture', 'tamper'])
def clean_text(text):
"""Text the screen can draw: known glyphs, accents folded to the base letter, at most TEXT_BYTES."""
out = []
for char in unicodedata.normalize('NFC', str(text)):
if char in GLYPHS:
out.append(char)
continue
base = unicodedata.normalize('NFKD', char)[0]
if base in GLYPHS:
out.append(base)
return short(''.join(out).strip(), TEXT_BYTES)
def number_text(value, precision=None):
"""A number as Home Assistant writes it in Dutch: decimal comma, dots between thousands."""
if precision is None:
# Without a display precision HA keeps integers whole and shows at most three decimals.
digits = 0 if float(value).is_integer() else 3
text = f'{value:.{digits}f}'
if '.' in text:
text = text.rstrip('0').rstrip('.')
else:
text = f'{value:.{precision}f}'
negative = text.startswith('-')
whole, _, fraction = text.lstrip('-').partition('.')
grouped = f'{int(whole):,}'.replace(',', '.')
result = grouped + (',' + fraction if fraction else '')
# "-0" and "-0,0" are just zero.
return '-' + result if negative and any(c not in '0.,' for c in result) else result
def with_unit(text, unit):
"""Home Assistant's spacing in Dutch: "21,3 °C", "65%", "18°"."""
if not unit:
return text
return text + unit if unit in ('%', '°') else f'{text} {unit}'
def precision_of(entry):
options = ((entry or {}).get('options') or {}).get('sensor') or {}
for key in ('display_precision', 'suggested_display_precision'):
value = options.get(key)
if type(value) is int and 0 <= value <= 6:
return value
return None
def numeric(value):
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def short_date(moment):
return f'{moment.day} {MONTHS[moment.month - 1]}'
def value(entity, state, entry=None, units=None, tz=None):
"""(text, unix time) an entity shows as its state; exactly one of them is set."""
domain = entity.split('.')[0]
raw = state.get('state') if state else None
attrs = (state or {}).get('attributes') or {}
if raw in (None, '', 'unavailable', 'unknown'):
return UNAVAILABLE, None
if domain in MOMENT_DOMAINS:
return (None, epoch(raw)) if epoch(raw) else (UNAVAILABLE, None)
if domain == 'script':
moment = epoch(attrs.get('last_triggered'))
return ('Bezig', None) if raw == 'on' else (None, moment) if moment else ('Nog niet gestart', None)
if domain == 'sun':
# The next event: sunset while the sun is up, sunrise while it is down.
clock = local_clock(attrs.get('next_setting' if raw == 'above_horizon' else 'next_rising'), tz)
return (clock or STATES.get(raw, raw)), None
if domain == 'weather':
temperature = numeric(attrs.get('temperature'))
if temperature is None:
return UNAVAILABLE, None
return with_unit(number_text(temperature, 0), attrs.get('temperature_unit') or (units or {}).get('temperature', '°C')), None
if domain == 'climate':
current = numeric(attrs.get('current_temperature'))
if current is not None:
return with_unit(number_text(current, 1), (units or {}).get('temperature', '°C')), None
if domain in ('person', 'device_tracker'):
return STATES.get(raw, raw), None
if domain == 'binary_sensor':
pair = BINARY_STATES.get(attrs.get('device_class'), ('Aan', 'Uit'))
return (pair[0] if raw == 'on' else pair[1] if raw == 'off' else raw), None
if domain == 'input_datetime':
# "2026-09-15 07:30:00", "2026-09-15" or just "07:30:00".
try:
moment = datetime.fromisoformat(raw) if attrs.get('has_date') else datetime.combine(datetime.min, time.fromisoformat(raw))
except ValueError:
return raw, None
if attrs.get('has_date') and attrs.get('has_time'):
return f'{short_date(moment)} {moment:%H:%M}', None
return (short_date(moment) if attrs.get('has_date') else f'{moment:%H:%M}'), None
if domain == 'sensor' and attrs.get('device_class') == 'timestamp':
return (None, epoch(raw)) if epoch(raw) else (raw, None)
if domain == 'sensor' and attrs.get('device_class') == 'date':
try:
return short_date(datetime.fromisoformat(raw)), None
except ValueError:
return raw, None
number = numeric(raw)
if number is not None and domain in ('sensor', 'number', 'input_number', 'counter', 'zone'):
return with_unit(number_text(number, precision_of(entry)), attrs.get('unit_of_measurement')), None
if domain in ('input_text', 'input_select', 'select'):
return raw, None
if raw in STATES:
return STATES[raw], None
return raw[:1].upper() + raw[1:], None
def active(entity, state):
""""Alleen als actief": on, open, home, detected, playing, heating, or a number other than zero."""
raw = state.get('state') if state else None
domain = entity.split('.')[0]
if raw in (None, '', 'unavailable', 'unknown'):
return False
if domain in ('person', 'device_tracker'):
return raw == 'home'
if domain in MOMENT_DOMAINS or domain in ('weather', 'sun') and raw != 'below_horizon':
return True
number = numeric(raw)
if number is not None:
return number != 0
return raw not in INACTIVE
def accent(entity, state):
"""Icon colour while an entity is active, like Home Assistant's badges; None keeps the calm grey."""
raw = state.get('state') if state else None
domain = entity.split('.')[0]
attrs = (state or {}).get('attributes') or {}
if domain == 'binary_sensor' and raw == 'on':
return RED if attrs.get('device_class') in ALARM_CLASSES else AMBER
if domain in ('light', 'switch', 'input_boolean', 'fan') and raw == 'on':
return AMBER
if domain in ('person', 'device_tracker') and raw == 'home':
return GREEN
if domain == 'climate' and raw in ('heat', 'cool'):
return ORANGE if raw == 'heat' else BLUE
# Home Assistant's own state colours: armed and locked green, on their way orange, alarm and open red.
if domain == 'alarm_control_panel':
return RED if raw == 'triggered' else ORANGE if raw in ('arming', 'pending') else GREEN if raw and raw.startswith('armed') else None
if domain == 'lock':
return GREEN if raw == 'locked' else ORANGE if raw in ('locking', 'unlocking', 'opening') else RED if raw in ('unlocked', 'open', 'jammed') else None
if domain == 'media_player' and raw == 'playing':
return BLUE
if domain == 'vacuum' and raw == 'cleaning':
return TEAL
return None
def auto_icon(entity, state):
"""Codepoint of the icon Home Assistant would show: its own mdi icon, else the device class, else the domain."""
attrs = (state or {}).get('attributes') or {}
raw = (state or {}).get('state')
own = tile_icons.ha_icon(attrs)
if own:
return own
domain, device_class = entity.split('.')[0], attrs.get('device_class')
if domain == 'weather':
name = tile_icons.WEATHER.get(raw, 'weather-partly-cloudy')
elif domain == 'sun':
name = 'weather-sunset-down' if raw == 'above_horizon' else 'weather-sunset-up'
elif domain == 'binary_sensor' and device_class in BINARY_ICONS:
name = BINARY_ICONS[device_class][0 if raw == 'on' else 1]
elif domain == 'sensor' and device_class in SENSOR_ICONS:
name = SENSOR_ICONS[device_class]
elif domain == 'lock' and raw in ('unlocked', 'open', 'unlocking'):
name = 'lock-open-variant'
else:
name = DOMAIN_ICONS.get(domain, tile_icons.FALLBACK)
return tile_icons.GLYPHS[name]
def entity_item(item, states, registry=None, units=None, tz=None):
"""(wire item, shown) for one entity item; the wire item exists even while hidden, for the editor."""
entity = item['entity']
state = states.get(entity)
wire = {'k': 'text'}
if item['icon'] == 'auto':
wire['i'] = auto_icon(entity, state)
elif item['icon'] != 'none':
wire['i'] = tile_icons.ICONS[item['icon']][0]
if item['content'] == 'last_changed':
moment = epoch((state or {}).get('last_changed'))
text, moment = (None, moment) if moment else (UNAVAILABLE, None)
else:
text, moment = value(entity, state, (registry or {}).get(entity), units, tz)
if moment:
wire.update(k='ago', e=moment)
else:
wire['t'] = clean_text(text) or UNAVAILABLE
color = accent(entity, state)
if color:
wire['c'] = color
return wire, item['show'] == 'always' or active(entity, state)
def message(layout, states, registry=None, units=None, tz=None):
items = []
for item in header_items(layout):
if item['type'] in HEADER_BUILTIN:
items.append({'k': item['type']})
continue
wire, shown = entity_item(item, states, registry, units, tz)
if shown:
items.append(wire)
return {'v': 1, 'op': 'header', 'items': items}
def preview(header, states, registry=None, units=None, tz=None):
"""What the editor's mockup shows per item, hidden ones included and marked."""
result = []
for item in header['items']:
if item['type'] in HEADER_BUILTIN:
result.append({'k': item['type'], 'name': HEADER_BUILTIN[item['type']], 'shown': True})
continue
wire, shown = entity_item(item, states, registry, units, tz)
attrs = (states.get(item['entity']) or {}).get('attributes') or {}
result.append({**wire, 'name': attrs.get('friendly_name') or item['entity'], 'shown': shown,
'auto_icon': auto_icon(item['entity'], states.get(item['entity']))})
return result
def suggestions(screen, entities, states, registry=None, limit=8):
"""A handful of useful items for this screen: its own room's climate and power first, then the house.
The room is the screen's area, or an area named like the screen. Diagnostic entities (a plug's own
temperature) never qualify."""
rooms = {name.casefold() for name in (screen.get('area'), screen.get('name'), (screen.get('layout') or {}).get('title')) if name}
def attrs(entity):
return (states.get(entity['id']) or {}).get('attributes') or {}
def usable(entity):
return ((states.get(entity['id']) or {}).get('state') not in (None, 'unavailable', 'unknown')
and not ((registry or {}).get(entity['id']) or {}).get('entity_category'))
def pick(label, test, content='state'):
found = sorted((e for e in entities if test(e) and usable(e)), key=lambda e: (e['area'].casefold() not in rooms, e['name'].casefold()))
if found:
entity = found[0]
picks.append({'label': label, 'name': entity['name'], 'area': entity['area'], 'icon': auto_icon(entity['id'], states.get(entity['id'])),
'item': {'type': 'entity', 'entity': entity['id'], 'content': content, 'icon': 'auto', 'show': 'always'}})
def sensor(device_class):
return lambda e: e['id'].startswith('sensor.') and attrs(e).get('device_class') == device_class and numeric(states[e['id']]['state']) is not None
picks = []
pick('Temperatuur', sensor('temperature'))
pick('Luchtvochtigheid', sensor('humidity'))
pick('Stroomverbruik', sensor('power'))
pick('Laatste beweging', lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed')
pick('Weer', lambda e: e['id'].startswith('weather.'))
pick('Aantal thuis', lambda e: e['id'] == 'zone.home')
pick('Zon op en onder', lambda e: e['id'] == 'sun.sun')
pick('CO₂', sensor('carbon_dioxide'))
return picks[:limit]
def catalogue():
"""Choices the editor offers; labels in the order the sheet shows them."""
return {'builtin': [{'type': key, 'label': label} for key, label in HEADER_BUILTIN.items()],
'contents': [{'key': key, 'label': CONTENT_LABELS[key]} for key in HEADER_CONTENTS],
'shows': [{'key': key, 'label': SHOW_LABELS[key]} for key in HEADER_SHOWS],
'max_items': HEADER_MAX_ITEMS, 'min_firmware': '.'.join(map(str, HEADER_MIN_FIRMWARE))}
+42 -3
View File
@@ -14,7 +14,8 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BUILTIN, TILE_BACKGROUNDS, alert_reference, alert_service, controls_catalogue, discover, extras, min_firmware, pack_slots, packets, state_message, validate_layout, validate_settings
from core import BUILTIN, HEADER_MIN_FIRMWARE, TILE_BACKGROUNDS, alert_reference, alert_service, controls_catalogue, discover, extras, header_items, min_firmware, pack_slots, packets, state_message, validate_header, validate_layout, validate_settings
import header_bar
from zoneinfo import ZoneInfo
LOG = logging.getLogger('screen_manager')
@@ -42,6 +43,8 @@ class HomeAssistant:
self.registry_changed = asyncio.Event()
self.setting_events = []
self.time_zone = None
# Home Assistant's unit system; a climate entity's temperature carries no unit of its own.
self.units = {}
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -119,7 +122,9 @@ class HomeAssistant:
self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries()
try:
self.time_zone = ZoneInfo((await self.request('get_config')).get('time_zone') or 'UTC')
config = await self.request('get_config')
self.units = config.get('unit_system') or {}
self.time_zone = ZoneInfo(config.get('time_zone') or 'UTC')
except Exception:
self.time_zone = timezone.utc
self.online = True
@@ -213,6 +218,7 @@ class Manager:
self.listeners = set() # asyncio.Event per open /api/events stream
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
self.updates = Updater(self, self.path.parent / 'updates.json')
self._registry_source, self._registry_index = None, {}
if self.path.exists():
raw = json.loads(self.path.read_text())
# Versioned persistent data. Never silently overwrite an unknown schema.
@@ -236,6 +242,19 @@ class Manager:
version=self.firmware_version(inbox)
return bool(version) and version >= (0,2,7)
def supports_header(self, inbox, screen=None):
return (self.firmware_version(inbox, screen) or (0, 0, 0)) >= HEADER_MIN_FIRMWARE
def registry_index(self):
"""Entity registry by id (display precision, entity category); rebuilt only when HA delivers a new registry."""
registry = getattr(self.ha, 'registry', [])
if self._registry_source is not registry:
self._registry_source, self._registry_index = registry, {item['entity_id']: item for item in registry}
return self._registry_index
def header_message(self, layout):
return header_bar.message(layout, self.ha.states, self.registry_index(), getattr(self.ha, 'units', {}), getattr(self.ha, 'time_zone', None))
def needs_firmware(self, inbox, layout, screen=None):
"""Version string the screen must run first, or None when the layout can be sent."""
needed=min_firmware(layout)
@@ -252,9 +271,14 @@ class Manager:
needed = self.needs_firmware(inbox, layout, screen)
if needed:
raise ValueError(f"Installeer eerst schermfirmware {needed} of nieuwer voor deze tegels.")
# A still-open older UI may save tiles without the new optional settings.
# A still-open older UI may save tiles without the new optional settings or top bar.
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy()
if 'header' not in layout and 'header' in self.layouts.get(inbox, {}):
layout['header'] = self.layouts[inbox]['header']
# Firmware before the top bar only knows show_clock; it follows the clock item.
if 'header' in layout and 'settings' in layout:
layout['settings']['show_clock'] = any(item['type'] == 'clock' for item in layout['header']['items'])
if 'settings' in layout and 'swipe_pages' not in data.get('settings',{}):
layout['settings']['swipe_pages']=self.layouts.get(inbox,{}).get('settings',{}).get('swipe_pages',False)
if 'settings' in layout and 'rotation' not in data.get('settings',{}):
@@ -278,6 +302,8 @@ class Manager:
known = {e['id'] for e in entities} | set(BUILTIN)
if any(t['entity'] not in known for t in layout['tiles']):
raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.')
if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)):
raise ValueError('Een entiteit in de bovenbalk bestaat niet meer. Kies een andere.')
updated = {**self.layouts, inbox: layout}
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix('.tmp')
@@ -313,6 +339,7 @@ class Manager:
def watched_entities(self):
"""Entities whose state changes matter: tiles on any layout plus the screens' own diagnostics."""
watched = {tile['entity'] for layout in self.layouts.values() for tile in layout['tiles']}
watched |= {item['entity'] for layout in self.layouts.values() for item in header_items(layout) if item['type'] == 'entity'}
for item in getattr(self.ha, 'registry', []):
if item.get('platform') == 'esphome' and item.get('original_name') in SCREEN_ENTITY_NAMES:
watched.add(item['entity_id'])
@@ -344,6 +371,9 @@ class Manager:
messages[0]['swipe_pages'] = layout['settings'].get('swipe_pages',False)
if screen.get('board')=='guition':
messages[0]['rotation'] = layout['settings'].get('rotation',0)
# The top bar right after the layout (firmware 0.2.32+; older firmware draws the clock of show_clock).
if self.supports_header(inbox, screen):
messages.append(self.header_message(layout))
for i,tile in enumerate(layout['tiles']):
forecast=hourly=None
if tile['entity'].startswith('weather.') and hasattr(self.ha,'forecast'):
@@ -468,7 +498,15 @@ def create_app(manager, development=False):
payload['icons'] = tile_icons.editor()
payload['alerts'] = alert_reference()
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}}
return web.json_response(payload)
async def header_preview(request):
"""The top bar as a screen would draw it right now, so the editor shows unsaved changes live."""
data = await request.json()
header = validate_header(data.get('header') if isinstance(data, dict) else None)
return web.json_response({'items': header_bar.preview(header, manager.ha.states, manager.registry_index(),
getattr(manager.ha, 'units', {}), getattr(manager.ha, 'time_zone', None))})
async def events(request):
"""Server-sent events: pushes the light inventory whenever it changes, so the page need not poll."""
response = web.StreamResponse(headers={'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store',
@@ -534,6 +572,7 @@ def create_app(manager, development=False):
app.router.add_post('/api/firmware/profiles', firmware_create)
app.router.add_get('/', index)
app.router.add_get('/api/inventory', inventory)
app.router.add_post('/api/header-preview', header_preview)
app.router.add_get('/api/events', events)
app.router.add_put('/api/screens/{inbox}', save)
app.router.add_static('/static/', static)
+573 -20
View File
@@ -74,9 +74,11 @@ function select(id) {
$("#dirty").textContent = "Alles opgeslagen";
$("#save-detail").textContent = "Aanpassen kan zonder opnieuw flashen.";
renderScreens();
renderTopbar();
renderTiles();
renderResults();
renderSettings();
loadTopbarPreview(0);
}
const settingDefinitions = [
["standby_enabled", "Automatisch standby", "check", true],
@@ -87,7 +89,7 @@ const settingDefinitions = [
["night_brightness", "Helderheid in nachtstand", "range", 10, 0, 100],
["night_start", "Nachtstand vanaf", "time", 1320],
["night_end", "Nachtstand tot", "time", 420],
["show_clock", "Klok tonen", "check", true],
// Whether the clock shows is up to the top bar now; the add-on keeps show_clock in step for older firmware.
["clock_24h", "24-uursklok (uit = 12 uur)", "check", true],
["home_on_standby", "Na standby terug naar pagina 1", "check", false],
["rotation", "Scherm draaien (met de klok mee)", "rotation", 0],
@@ -623,13 +625,13 @@ function renderPreview(preview) {
const moving = preview?.moving || null;
const bySlot = new Map(entries.map((e) => [e.slot, e]));
const covered = new Set(entries.filter((e) => isWide(e.tile)).map((e) => e.slot + 1));
const pages = pageCount(entries), title = $("#title").value || "Thuis";
const pages = pageCount(entries);
// While dragging, one more page waits below the last one.
const shown = drag.active && pages < MAX_PAGES ? pages + 1 : pages;
for (let page = 0; page < shown; page++) {
const frame = node("section", undefined, "screen-preview");
const heading = node("div", undefined, "preview-heading");
heading.append(node("small", `Pagina ${page + 1} · ${title}`));
heading.append(node("small", `Pagina ${page + 1}`));
if (page >= pages) {
frame.classList.add("new-page");
heading.replaceChildren(node("small", `Pagina ${page + 1} · sleep hierheen voor een nieuwe pagina`));
@@ -642,6 +644,7 @@ function renderPreview(preview) {
heading.append(drop);
}
frame.append(heading);
if (page < pages) frame.append(topbarBar());
const grid = node("div", undefined, "preview-grid");
for (let cell = 0; cell < SLOTS_PER_PAGE; cell++) {
const slot = page * SLOTS_PER_PAGE + cell;
@@ -652,6 +655,7 @@ function renderPreview(preview) {
frame.append(grid);
root.append(frame);
}
fitTopbars();
}
// A card on the mockup. A placeholder is the tile being dragged, drawn where it will land.
function tileCard(tile, slot, placeholder) {
@@ -775,19 +779,29 @@ function segmented(choices, value, onChange) {
// 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 fromHA = Boolean(inventory.entities.find((e) => e.id === tile.entity)?.icon);
return iconPicker({
selected: tile.options?.icon || "auto",
automatic: automaticIcon(tile.entity),
autoLabel: `Automatisch (${fromHA ? "uit Home Assistant" : "standaard"})`,
onPick: (name) => { tile.options = { ...tile.options, icon: name }; markDirty(); renderPreview(); onChange(); },
note: supportsFirmware(0, 2, 18) ? "" : "Het scherm toont een gekozen icoon vanaf firmware 0.2.18.",
});
}
// The icon choice for tiles and top bar items: automatic, optionally none, or one from the set.
function iconPicker({ selected, automatic, autoLabel, allowNone = false, onPick, note = "" }) {
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"})`;
let picked = selected;
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;
const chosen = iconNamed(picked);
current.textContent = picked === "none" ? "" : glyph(chosen?.cp || automatic);
text.textContent = picked === "none" ? "Geen icoon" : chosen?.label || autoLabel;
};
describe();
const panel = node("div", undefined, "icon-picker");
@@ -805,15 +819,13 @@ function iconField(tile, onChange) {
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.setAttribute("aria-pressed", String(picked === name));
b.append(node("span", cp ? glyph(cp) : "", "mdi"));
b.onclick = () => {
tile.options = { ...tile.options, icon: name };
picked = name;
for (const other of panel.querySelectorAll(".icon-choice")) other.setAttribute("aria-pressed", String(other === b));
describe();
markDirty();
renderPreview();
onChange();
onPick(name);
};
return b;
};
@@ -821,8 +833,10 @@ function iconField(tile, onChange) {
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");
const auto = choice("auto", automatic, autoLabel, "icon-auto");
auto.append(node("span", autoLabel));
const none = allowNone ? choice("none", "", "Geen icoon", "icon-auto icon-none") : null;
none?.append(node("span", "Geen icoon, alleen tekst"));
const list = node("div", undefined, "icon-list"), empty = node("p", "Geen icoon gevonden.", "hint");
empty.hidden = true;
const sections = inventory.icons.groups.map((group) => {
@@ -850,9 +864,9 @@ function iconField(tile, onChange) {
}
empty.hidden = found > 0;
};
panel.append(search, auto, list, empty);
panel.append(search, auto, ...(none ? [none] : []), 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"));
if (note) wrap.append(node("small", note, "icon-hint"));
return wrap;
}
function renderTileSheet() {
@@ -961,6 +975,541 @@ function renderTileSheet() {
foot.append(done);
sheet.append(head, body, foot);
}
// ---- Top bar ----
// The name on the left; on the right up to six items: the time, an analog clock, the date, or an
// entity's state or last change. The add-on formats entity text (POST header-preview) exactly as the
// screen gets it; the screen and this mockup tick clocks and "5 min geleden" themselves.
const MONTHS = ["jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"];
const WEEKDAYS = ["zo", "ma", "di", "wo", "do", "vr", "za"];
const BUILTIN_ICONS = { clock: "clock-outline", analog: "clock-outline", date: "calendar" };
let topbarPreviews = new Map(), topbarTimer = 0, topbarSheetIndex = null, topbarOverflow = new Set(), topbarAdded = null;
const itemKey = (item) => JSON.stringify(item);
// Without a stored top bar the screen shows what it always did: the clock of show_clock.
const topbarItems = () => layout.header?.items ?? ((layout.settings?.show_clock ?? true) ? [{ type: "clock" }] : []);
const topbarMax = () => inventory.header?.max_items || 6;
function setTopbarItems(items) {
layout.header = { items };
markDirty();
renderTopbar();
renderBars();
loadTopbarPreview();
}
// A caption over a group of buttons; unlike a label it never forwards a click to the first one.
function group(title, content) {
const wrap = node("div", undefined, "sheet-field");
wrap.append(node("span", title), content);
return wrap;
}
// Only the bars of the mockup pages, so tiles, focus and a running drag stay untouched.
function renderBars() {
const bars = document.querySelectorAll("#layout-preview .preview-bar-wrap");
if (!bars.length) return renderPreview();
for (const bar of bars) bar.replaceWith(topbarBar());
fitTopbars();
}
// Entity text as the screen will show it, for the items not previewed yet.
function loadTopbarPreview(delay = 150) {
clearTimeout(topbarTimer);
topbarTimer = setTimeout(async () => {
const items = topbarItems();
if (!items.some((item) => item.type === "entity")) return;
try {
const data = await (await api("header-preview", { method: "POST", body: JSON.stringify({ header: { items } }) })).json();
items.forEach((item, i) => topbarPreviews.set(itemKey(item), data.items[i]));
if (!layout) return;
renderTopbar();
renderBars();
if (topbarSheetIndex !== null && $("#topbar-sheet").open) renderTopbarLive();
} catch {
// Keep the last preview; the next edit or refresh tries again.
}
}, delay);
}
function clockText(now = new Date()) {
let hours = now.getHours();
// The screen formats with %I:%M when the 24-hour clock is off.
if (!(layout.settings?.clock_24h ?? true)) hours = hours % 12 || 12;
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
}
const dateText = (now = new Date()) => `${WEEKDAYS[now.getDay()]} ${now.getDate()} ${MONTHS[now.getMonth()]}`;
// Same wording and thresholds as header_bar::ago_text() in the firmware.
function agoText(then, now = Math.floor(Date.now() / 1000)) {
const seconds = now - then, span = Math.abs(seconds), per = (unit) => Math.floor(span / unit);
if (seconds < 0) {
if (span < 3600) return `Over ${Math.max(1, per(60))} min`;
if (span < 86400) return `Over ${per(3600)} uur`;
if (span < 172800) return "Morgen";
return `Over ${per(86400)} dagen`;
}
if (span < 60) return "Zojuist";
if (span < 3600) return `${per(60)} min geleden`;
if (span < 86400) return `${per(3600)} uur geleden`;
if (span < 172800) return "Gisteren";
if (span < 604800) return `${per(86400)} dagen geleden`;
if (span < 2592000) return per(604800) === 1 ? "1 week geleden" : `${per(604800)} weken geleden`;
if (span < 31536000) return per(2592000) === 1 ? "1 maand geleden" : `${per(2592000)} maanden geleden`;
return `${per(31536000)} jaar geleden`;
}
function topbarLabel(item) {
if (item.type === "entity") return entityName(item.entity);
return inventory.header?.builtin.find((b) => b.type === item.type)?.label || item.type;
}
// What the item shows right now: { icon, text, color, shown }. Entities wait for the add-on's preview.
function topbarView(item) {
if (item.type === "clock") return { text: clockText(), shown: true };
if (item.type === "date") return { text: dateText(), shown: true };
if (item.type === "analog") return { analog: true, shown: true };
const p = topbarPreviews.get(itemKey(item));
if (!p) return { icon: item.icon === "none" ? null : iconNamed(item.icon)?.cp || automaticIcon(item.entity), text: "…", shown: true, loading: true };
return { icon: p.i || null, text: p.k === "ago" ? agoText(p.e) : p.t, color: p.c ? `#${p.c}` : null, shown: p.shown };
}
// ---- The bar as the screen draws it ----
// One rule set with header_bar.h in the firmware, in screen pixels of the board: every value (the
// time too) in the same 400 Roboto on the name's baseline; icons and the dial centred on the
// height of the digits; the gaps measured between what you see (glyph ink), not between boxes, so
// an icon with side bearings sits exactly as close to its value as one without.
const BAR_METRICS = {
guition: { width: 448, top: 36, name: 27, text: 21, icon: 26 },
cyd: { width: 298, top: 24, name: 18, text: 14, icon: 18 },
};
// Screens without the Guition type sensor are CYDs, as the rotation setting assumes too.
const barMetrics = () => BAR_METRICS[inventory.screens.find((s) => s.id === selected)?.board === "guition" ? "guition" : "cyd"];
const measure = document.createElement("canvas").getContext("2d");
const inkCache = new Map();
// Ink box of a string relative to its origin on the baseline: left/right, and top (negative, up)/bottom.
function inkOf(text, font) {
const key = `${font}|${text}`;
if (!inkCache.has(key)) {
measure.font = font;
const m = measure.measureText(text);
inkCache.set(key, { left: -m.actualBoundingBoxLeft, right: m.actualBoundingBoxRight, top: -m.actualBoundingBoxAscent, bottom: m.actualBoundingBoxDescent, advance: m.width });
}
return inkCache.get(key);
}
const barFonts = (m) => ({ name: `500 ${m.name}px "Bar Roboto"`, text: `400 ${m.text}px "Bar Roboto"`, icon: `${m.icon}px "Tile Icons"` });
// The fonts load on first use; measurements before that are wrong, so draw again once they are in.
Promise.all([document.fonts.load('500 27px "Bar Roboto"', "Studio 0"), document.fonts.load('400 21px "Bar Roboto"', "Weg 0"), document.fonts.load('26px "Tile Icons"', String.fromCodePoint(0xf0150))])
.then(() => { inkCache.clear(); if (layout) { renderTopbar(); renderBars(); } })
.catch(() => {});
// Same integer arithmetic as header_bar::gaps() in the firmware, from the digit height in pixels.
function barGaps(cap) {
return { icon: Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10)) };
}
// The parts per item with their ink widths, the placement, and which items fall off.
function barLayout(items = topbarItems(), metrics = barMetrics(), nameText = $("#title").value || "Thuis") {
const fonts = barFonts(metrics);
// The firmware reads the digit height as a whole number of pixels (the glyph box of "0").
const zero = inkOf("0", fonts.text), cap = Math.round(zero.bottom - zero.top), gaps = barGaps(cap);
const dialInk = inkOf(String.fromCodePoint(0xf0150), fonts.icon), dial = Math.round(dialInk.bottom - dialInk.top);
const parts = items.map((item, index) => {
const view = topbarView(item);
const part = { index, item, view, shown: view.shown, width: 0 };
if (view.analog) { part.dial = dial; part.width = dial; return part; }
if (view.icon) { part.icon = { glyph: glyph(view.icon), ink: inkOf(glyph(view.icon), fonts.icon) }; part.width += part.icon.ink.right - part.icon.ink.left; }
if (view.text) {
part.text = { value: view.text, ink: inkOf(view.text, fonts.text) };
part.width += (part.icon ? gaps.icon : 0) + part.text.ink.right - part.text.ink.left;
}
return part;
});
const shown = parts.filter((p) => p.shown);
const natural = inkOf(nameText, fonts.name).advance;
const minName = Math.min(natural, Math.floor((metrics.width * 35) / 100));
const total = (list) => list.reduce((sum, p) => sum + p.width, 0) + Math.max(0, list.length - 1) * gaps.item;
let first = 0;
while (first < shown.length && total(shown.slice(first)) + gaps.name + minName > metrics.width) first++;
const placed = shown.slice(first), dropped = new Set(shown.slice(0, first).map((p) => p.index));
let x = metrics.width - total(placed);
for (const p of placed) { p.x = x; x += p.width + gaps.item; }
const nameRoom = placed.length ? placed[0].x - gaps.name : metrics.width;
return { metrics, fonts, cap, zero, gaps, parts, placed, dropped, nameText, natural, nameRoom };
}
// LVGL's LV_LABEL_LONG_DOT: the longest start that fits with "..." after it.
function dotted(text, font, room) {
if (inkOf(text, font).advance <= room) return text;
const chars = [...text];
while (chars.length && inkOf(chars.join("") + "...", font).advance > room) chars.pop();
return chars.join("") + "...";
}
// `font` goes through CSSOM: the add-on's CSP blocks style attributes.
const svgNode = (tag, attrs = {}) => {
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "font") el.style.font = v;
else el.setAttribute(k, v);
}
return el;
};
// Draws placed parts into an SVG group; `baseline` is the text baseline in screen pixels.
function drawParts(group, lay, parts, baseline) {
const capMiddle = baseline + (lay.zero.top + lay.zero.bottom) / 2;
for (const p of parts) {
const color = p.view.color || "#46525e";
if (p.dial) {
const d = p.dial, stroke = Math.max(1, Math.round(d / 10)), hand = Math.max(1, Math.round(d * 0.075));
const cx = p.x + d / 2, now = new Date();
group.append(svgNode("circle", { cx, cy: capMiddle, r: d / 2 - stroke / 2, fill: "none", stroke: color, "stroke-width": stroke }));
for (const [angle, length] of [[(now.getHours() % 12 + now.getMinutes() / 60) * 30, d * 0.24], [now.getMinutes() * 6, d * 0.34]]) {
const rad = (angle * Math.PI) / 180;
group.append(svgNode("line", { x1: cx, y1: capMiddle, x2: cx + length * Math.sin(rad), y2: capMiddle - length * Math.cos(rad), stroke: color, "stroke-width": hand, "stroke-linecap": "round" }));
}
continue;
}
let x = p.x;
if (p.icon) {
const ink = p.icon.ink, icon = svgNode("text", { x: x - ink.left, y: capMiddle - (ink.top + ink.bottom) / 2, fill: color, font: lay.fonts.icon });
icon.textContent = p.icon.glyph;
group.append(icon);
x += ink.right - ink.left + (p.text ? lay.gaps.icon : 0);
}
if (p.text) {
const text = svgNode("text", { x: x - p.text.ink.left, y: baseline, fill: "#46525e", font: lay.fonts.text });
text.textContent = p.text.value;
group.append(text);
}
}
}
// The bar at the top of a mockup page, scaled with the page: name left, items right.
function topbarBar() {
const lay = barLayout(), m = lay.metrics;
const height = m.top + Math.round(m.name * 0.45);
const svg = svgNode("svg", { viewBox: `0 0 ${m.width} ${height}`, class: "preview-bar", role: "img" });
const name = svgNode("text", { x: 0, y: m.top, fill: "#1b1b1b", font: lay.fonts.name });
name.textContent = dotted(lay.nameText, lay.fonts.name, Math.min(lay.natural, lay.nameRoom));
svg.append(name);
const group = svgNode("g");
drawParts(group, lay, lay.placed, m.top);
svg.append(group);
svg.setAttribute("aria-label", `Bovenbalk: ${lay.nameText}`);
const wrap = node("div", undefined, "preview-bar-wrap");
wrap.title = "Bovenbalk aanpassen";
wrap.append(svg);
wrap.onclick = () => { $("#topbar").scrollIntoView({ behavior: "smooth", block: "center" }); $("#topbar").classList.add("flash"); setTimeout(() => $("#topbar").classList.remove("flash"), 900); };
return wrap;
}
// Which items the screen leaves out for lack of room; the chips mark them.
function fitTopbars() {
const dropped = barLayout().dropped;
const before = [...topbarOverflow].join();
topbarOverflow = dropped;
if ([...dropped].join() !== before) renderTopbar();
}
// One item on its own, drawn exactly as in the bar (the sheet's "zo staat het op het scherm").
function itemSample(item) {
const lay = barLayout([item]), m = lay.metrics, part = lay.parts[0];
part.x = 0;
const top = Math.round(m.text * 1.05), height = Math.round(m.text * 1.4);
const svg = svgNode("svg", { viewBox: `-2 0 ${Math.max(1, part.width) + 4} ${height}`, class: "topbar-sample", role: "img" });
svg.style.width = `${(Math.max(1, part.width) + 4) * (26 / m.icon)}px`;
const group = svgNode("g");
drawParts(group, lay, [part], top);
svg.append(group);
return svg;
}
function renderTopbar() {
if (!layout) return;
const items = topbarItems(), chips = $("#topbar-chips");
chips.replaceChildren();
items.forEach((item, index) => chips.append(topbarChip(item, index)));
const add = node("button", "+ Toevoegen", "topbar-add");
add.type = "button";
add.disabled = items.length >= topbarMax();
add.title = add.disabled ? `Maximaal ${topbarMax()} onderdelen` : "Tijd, datum, analoge klok of een entiteit toevoegen";
add.onclick = () => openTopbarSheet(-1);
chips.append(add);
$("#topbar-count").textContent = `${items.length} / ${topbarMax()}`;
const needed = inventory.header?.min_firmware || "0.2.32";
const [major, minor, patch] = needed.split(".").map(Number);
const hint = $("#topbar-hint");
hint.textContent = supportsFirmware(major, minor, patch)
? topbarOverflow.size ? "Niet alles past naast de naam: het scherm laat de gestreepte onderdelen weg. Haal er een weg of kies een kortere naam." : "Sleep om de volgorde te wijzigen; tik op een onderdeel om het in te stellen."
: `Sensoren, datum en de analoge klok verschijnen vanaf firmware ${needed}; tot die update toont dit scherm de naam en, als de tijd erin staat, de klok.`;
hint.classList.toggle("warn", topbarOverflow.size > 0 && supportsFirmware(major, minor, patch));
}
function topbarChip(item, index) {
const view = topbarView(item);
const chip = node("div", undefined, "topbar-chip");
chip.setAttribute("role", "listitem");
chip.tabIndex = 0;
chip.dataset.index = index;
chip.classList.toggle("is-hidden", !view.shown);
chip.classList.toggle("is-overflow", topbarOverflow.has(index));
chip.classList.toggle("just-added", topbarAdded?.key === itemKey(item) && Date.now() - topbarAdded.time < 1200);
chip.classList.toggle("dragging-chip", chipDrag.active && chipDrag.index === index);
const icon = node("span", "", "mdi chip-icon");
const cp = view.analog || item.type !== "entity" ? iconNamed(BUILTIN_ICONS[item.type])?.cp : view.icon;
if (cp) icon.textContent = glyph(cp);
if (view.color) icon.style.color = view.color;
const texts = node("span", undefined, "chip-text");
const detail = !view.shown ? "Verborgen: nu niet actief" : topbarOverflow.has(index) ? "Past niet naast de naam" : view.analog ? "Wijzerplaat" : view.text;
texts.append(node("strong", topbarLabel(item)), node("small", detail));
const remove = node("button", "✕", "chip-remove");
remove.type = "button";
remove.setAttribute("aria-label", `${topbarLabel(item)} uit de bovenbalk halen`);
remove.onclick = (e) => { e.stopPropagation(); removeTopbarItem(index); };
chip.append(icon, texts, remove);
chip.setAttribute("aria-label", `${topbarLabel(item)}, plek ${index + 1}. Enter: instellen, pijltjes: verplaatsen`);
chip.onclick = () => openTopbarSheet(index);
chip.onkeydown = (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTopbarSheet(index); return; }
const step = { ArrowLeft: -1, ArrowRight: 1 }[e.key];
if (step && moveTopbarItem(index, index + step)) $(`#topbar-chips [data-index="${index + step}"]`)?.focus();
};
enableChipDrag(chip, index);
return chip;
}
function moveTopbarItem(from, to) {
const items = [...topbarItems()];
if (to < 0 || to >= items.length || from === to) return false;
items.splice(to, 0, ...items.splice(from, 1));
setTopbarItems(items);
return true;
}
function removeTopbarItem(index) {
const items = [...topbarItems()];
const [item] = items.splice(index, 1);
if (!item) return;
if ($("#topbar-sheet").open) $("#topbar-sheet").close();
setTopbarItems(items);
toast(`${topbarLabel(item)} uit de bovenbalk gehaald`, {
label: "Ongedaan maken",
run: () => { const back = [...topbarItems()]; back.splice(Math.min(index, back.length), 0, item); setTopbarItems(back); },
});
}
function addTopbarItem(item) {
const items = topbarItems();
if (items.length >= topbarMax()) return toast(`De bovenbalk heeft plaats voor ${topbarMax()} onderdelen.`);
if (items.some((other) => itemKey(other) === itemKey(item))) return toast("Dit staat al in de bovenbalk.");
// The new chip lights up briefly so the eye finds it.
topbarAdded = { key: itemKey(item), time: Date.now() };
setTopbarItems([...items, item]);
$("#topbar-sheet").close();
}
// Pointer drag between chips, mouse and touch (touch after a short hold, so the page still scrolls).
// The order updates while dragging, the screen mockup follows, and a finished drag is not a click.
const chipDrag = { index: -1, start: null, timer: 0, active: false, pointerId: null, moved: false };
function enableChipDrag(chip, index) {
chip.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || e.target.closest(".chip-remove")) return;
if (e.pointerType !== "touch") e.preventDefault();
Object.assign(chipDrag, { index, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId, active: false, moved: false });
clearTimeout(chipDrag.timer);
if (e.pointerType === "touch") chipDrag.timer = setTimeout(() => beginChipDrag(), 260);
document.addEventListener("pointermove", moveChipDrag);
document.addEventListener("pointerup", endChipDrag);
document.addEventListener("pointercancel", endChipDrag);
});
}
function beginChipDrag() {
chipDrag.active = true;
document.body.classList.add("dragging");
document.addEventListener("touchmove", blockChipScroll, { passive: false });
$(`#topbar-chips [data-index="${chipDrag.index}"]`)?.classList.add("dragging-chip");
}
function blockChipScroll(e) { if (chipDrag.active) e.preventDefault(); }
function moveChipDrag(e) {
if (e.pointerId !== chipDrag.pointerId || !chipDrag.start) return;
if (!chipDrag.active) {
const distance = Math.hypot(e.clientX - chipDrag.start.x, e.clientY - chipDrag.start.y);
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(chipDrag.timer); chipDrag.start = null; } return; }
if (distance < 6) return;
beginChipDrag();
}
// The target is the chip under the pointer's row whose middle the pointer passed.
const chips = [...document.querySelectorAll("#topbar-chips .topbar-chip")];
let target = chipDrag.index;
chips.forEach((chip, i) => {
const r = chip.getBoundingClientRect();
if (e.clientY >= r.top - 6 && e.clientY <= r.bottom + 6) {
if (i < chipDrag.index && e.clientX < r.left + r.width / 2) target = Math.min(target, i);
if (i > chipDrag.index && e.clientX > r.left + r.width / 2) target = Math.max(target, i);
}
});
if (target !== chipDrag.index) {
const items = [...topbarItems()];
items.splice(target, 0, ...items.splice(chipDrag.index, 1));
layout.header = { items };
chipDrag.index = target;
chipDrag.moved = true;
renderTopbar();
renderBars();
}
}
function endChipDrag(e) {
if (e.pointerId !== chipDrag.pointerId) return;
clearTimeout(chipDrag.timer);
document.removeEventListener("pointermove", moveChipDrag);
document.removeEventListener("pointerup", endChipDrag);
document.removeEventListener("pointercancel", endChipDrag);
document.removeEventListener("touchmove", blockChipScroll);
document.body.classList.remove("dragging");
if (chipDrag.active) {
drag.suppressUntil = Date.now() + 400;
if (chipDrag.moved) markDirty();
renderTopbar();
}
Object.assign(chipDrag, { index: -1, start: null, active: false, pointerId: null, moved: false });
}
// One sheet for adding (index -1) and for one item's settings.
function openTopbarSheet(index) {
if (index !== topbarSheetIndex) iconPickerOpen = false;
topbarSheetIndex = index;
renderTopbarSheet();
if (!$("#topbar-sheet").open) $("#topbar-sheet").showModal();
}
$("#topbar-sheet").addEventListener("close", () => { topbarSheetIndex = null; });
function sheetHead(badge, title, subtitle) {
const head = node("div", undefined, "sheet-head"), titles = node("div");
titles.append(node("strong", title), node("small", subtitle));
const close = node("button", "✕", "quiet sheet-close");
close.type = "button";
close.setAttribute("aria-label", "Sluiten");
close.onclick = () => $("#topbar-sheet").close();
head.append(badge, titles, close);
return head;
}
function optionButton(cp, title, detail, onClick, disabled = false) {
const b = node("button", undefined, "topbar-option");
b.type = "button";
b.disabled = disabled;
const texts = node("span");
texts.append(node("strong", title), node("small", detail));
b.append(node("span", cp ? glyph(cp) : "", "mdi"), texts);
b.onclick = onClick;
return b;
}
function renderTopbarSheet() {
const sheet = $("#topbar-sheet");
sheet.replaceChildren();
if (topbarSheetIndex === -1) return renderTopbarAdd(sheet);
const item = topbarItems()[topbarSheetIndex];
if (!item) return sheet.close();
const badge = node("span", "", "domain-icon mdi");
const body = node("div", undefined, "sheet-body");
if (item.type === "entity") {
const view = topbarView(item);
badge.textContent = view.icon ? glyph(view.icon) : "";
sheet.append(sheetHead(badge, entityName(item.entity), item.entity));
const live = node("div", undefined, "topbar-live");
live.id = "topbar-live";
body.append(live);
// Choices apply live; an icon pick keeps the open picker (search, scroll) and only refreshes the samples.
const update = (patch) => {
const items = [...topbarItems()];
items[topbarSheetIndex] = { ...items[topbarSheetIndex], ...patch };
setTopbarItems(items);
renderTopbarLive();
const next = topbarView(items[topbarSheetIndex]);
badge.textContent = next.icon ? glyph(next.icon) : "";
};
const content = segmented(inventory.header.contents.map((c) => [c.key, c.label]), item.content, (v) => update({ content: v }));
const contentField = group("Wat laten zien", content);
contentField.append(node("small", "Laatst gewijzigd telt op het scherm zelf door: “Zojuist”, “5 min geleden”, “Gisteren”.", "field-hint"));
body.append(contentField);
body.append(iconPicker({
selected: item.icon,
automatic: topbarPreviews.get(itemKey(item))?.auto_icon || automaticIcon(item.entity),
autoLabel: "Automatisch (zoals Home Assistant)",
allowNone: true,
onPick: (name) => update({ icon: name }),
}));
const shows = segmented(inventory.header.shows.map((s) => [s.key, s.label]), item.show, (v) => update({ show: v }));
const showField = group("Tonen", shows);
showField.append(node("small", "Alleen als actief verbergt het onderdeel zolang het uit, dicht, weg of 0 is. Handig voor een open deur, een draaiende wasmachine of wie er thuis is.", "field-hint"));
body.append(showField);
} else {
badge.textContent = glyph(iconNamed(BUILTIN_ICONS[item.type])?.cp || "");
sheet.append(sheetHead(badge, topbarLabel(item), "Van het scherm zelf, werkt ook zonder Home Assistant"));
const live = node("div", undefined, "topbar-live");
live.id = "topbar-live";
body.append(live);
if (item.type !== "date") {
const format = segmented([["24", "24 uur"], ["12", "12 uur"]], (layout.settings?.clock_24h ?? true) ? "24" : "12", (v) => {
layout.settings = { ...Object.fromEntries(settingDefinitions.map(([key, , , value]) => [key, value])), ...layout.settings, clock_24h: v === "24" };
markDirty();
renderSettings();
renderTopbar();
renderBars();
renderTopbarLive();
});
const formatField = group("Notatie", format);
formatField.append(node("small", "Geldt voor elke klok op dit scherm, ook de kloktegels.", "field-hint"));
body.append(formatField);
}
}
const foot = node("div", undefined, "sheet-foot");
const remove = node("button", "Weghalen", "quiet danger");
remove.type = "button";
remove.onclick = () => removeTopbarItem(topbarSheetIndex);
const left = node("button", "← Naar links", "quiet"), right = node("button", "Naar rechts →", "quiet");
left.type = right.type = "button";
left.disabled = topbarSheetIndex === 0;
right.disabled = topbarSheetIndex >= topbarItems().length - 1;
left.onclick = () => { if (moveTopbarItem(topbarSheetIndex, topbarSheetIndex - 1)) { topbarSheetIndex--; renderTopbarSheet(); } };
right.onclick = () => { if (moveTopbarItem(topbarSheetIndex, topbarSheetIndex + 1)) { topbarSheetIndex++; renderTopbarSheet(); } };
const done = node("button", "Klaar");
done.type = "button";
done.onclick = () => sheet.close();
foot.append(remove, left, right, done);
sheet.append(body, foot);
renderTopbarLive();
}
// "Op het scherm": the item as the bar draws it, updated while choosing.
function renderTopbarLive() {
const live = $("#topbar-live"), item = topbarItems()[topbarSheetIndex];
if (!live || !item) return;
const view = topbarView(item);
const note = !view.shown ? "Nu verborgen: niet actief" : topbarOverflow.has(topbarSheetIndex) ? "Past nu niet naast de naam" : "Zo staat het op het scherm";
live.replaceChildren(node("small", note), itemSample(item));
}
function renderTopbarAdd(sheet) {
const badge = node("span", "+", "domain-icon");
sheet.append(sheetHead(badge, "Toevoegen aan de bovenbalk", `${topbarItems().length} van ${topbarMax()} plekken gebruikt`));
const body = node("div", undefined, "sheet-body");
const taken = new Set(topbarItems().map(itemKey));
const own = node("div", undefined, "topbar-options");
const samples = { clock: clockText(), analog: "Kleine wijzerplaat met de tijd", date: dateText() };
for (const builtin of inventory.header?.builtin || []) {
const item = { type: builtin.type };
own.append(optionButton(iconNamed(BUILTIN_ICONS[builtin.type])?.cp, builtin.label, taken.has(itemKey(item)) ? "Staat er al in" : samples[builtin.type], () => addTopbarItem(item), taken.has(itemKey(item))));
}
body.append(group("Van het scherm zelf", own));
const suggested = inventory.header?.suggestions?.[selected] || [];
if (suggested.length) {
const list = node("div", undefined, "topbar-options");
for (const s of suggested) {
const exists = taken.has(itemKey(s.item));
list.append(optionButton(s.icon || automaticIcon(s.item.entity), s.label, exists ? "Staat er al in" : [s.name, s.area].filter(Boolean).join(" · "), () => addTopbarItem(s.item), exists));
}
body.append(group("Suggesties uit Home Assistant", list));
}
const search = node("input");
search.type = "search";
search.placeholder = "Zoek op naam, ruimte of entiteit, bijv. temperatuur of deur";
const results = node("div", undefined, "topbar-results");
const renderMatches = () => {
const query = search.value.trim().toLocaleLowerCase();
const matches = inventory.entities.filter((e) => `${e.name} ${e.id} ${e.area} ${e.device}`.toLocaleLowerCase().includes(query));
results.replaceChildren(...matches.slice(0, 40).map((e) => {
const item = { type: "entity", entity: e.id, content: "state", icon: "auto", show: "always" };
return optionButton(automaticIcon(e.id), e.name, [e.area, e.id].filter(Boolean).join(" · "), () => addTopbarItem(item), taken.has(itemKey(item)));
}));
if (!matches.length) results.append(node("p", "Geen entiteiten gevonden.", "hint"));
else if (matches.length > 40) results.append(node("p", `${matches.length} resultaten. Typ verder om te verfijnen.`, "hint"));
};
search.oninput = renderMatches;
search.setAttribute("aria-label", "Zoek een entiteit voor de bovenbalk");
const searchField = group("Een entiteit", search);
searchField.append(results);
body.append(searchField);
renderMatches();
const foot = node("div", undefined, "sheet-foot");
const cancel = node("button", "Annuleren", "quiet");
cancel.type = "button";
cancel.onclick = () => sheet.close();
foot.append(cancel);
sheet.append(body, foot);
}
// A click in the picker: the marked empty cell, else the first free cell.
function addTile(id) {
if (layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit()) return;
@@ -1481,6 +2030,13 @@ function poll() {
}
listen();
poll();
// The mockup's clocks tick and entity values in the top bar follow Home Assistant while the page is open.
setInterval(() => {
if (!layout || document.hidden || drag.active || chipDrag.active) return;
loadTopbarPreview(0);
renderTopbar();
renderBars();
}, 30000);
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
lastFull = Date.now();
@@ -1575,9 +2131,6 @@ function openSection(id) {
if (section.tagName === "DETAILS") section.open = true;
section.scrollIntoView({ behavior: "smooth", block: "start" });
}
$("#nav-tiles").onclick = () => openSection("#tile-section");
$("#nav-settings").onclick = () => openSection("#general-settings");
$("#nav-inspector").onclick = () => inspect();
async function inspect(entity) {
if (!selected) return;
openSection("#inspector-section");
Binary file not shown.
Binary file not shown.
+25 -13
View File
@@ -85,19 +85,30 @@
</div>
<span id="delivery" class="badge"></span>
</div>
<label
>Naam bovenaan het scherm<input
id="title"
maxlength="60"
placeholder="Bijvoorbeeld: Woonkamer"
/></label>
<nav class="editor-nav" aria-label="Scherm beheren">
<button id="nav-tiles" class="quiet">Tegels instellen</button>
<button id="nav-settings" class="quiet">
Algemene instellingen
</button>
<button id="nav-inspector" class="quiet">Inspector</button>
</nav>
<section class="topbar-editor" id="topbar" aria-labelledby="topbar-title">
<div class="section-title">
<h3 id="topbar-title">Bovenbalk</h3>
<span id="topbar-count"></span>
</div>
<div class="topbar-fields">
<label class="topbar-name"
>Links: naam<input
id="title"
maxlength="60"
placeholder="Bijvoorbeeld: Woonkamer"
/></label>
<div class="topbar-right">
<span class="topbar-caption" id="topbar-caption">Rechts: tijd, sensoren of een klok</span>
<div
id="topbar-chips"
class="topbar-chips"
role="list"
aria-labelledby="topbar-caption"
></div>
</div>
</div>
<p class="hint" id="topbar-hint"></p>
</section>
<div class="columns" id="tile-section">
<div class="tile-editor">
<div class="section-title">
@@ -311,6 +322,7 @@
<pre id="firmware-log"></pre>
</dialog>
<dialog id="tile-sheet" class="tile-sheet" aria-label="Tegel instellen"></dialog>
<dialog id="topbar-sheet" class="tile-sheet" aria-label="Bovenbalk"></dialog>
<dialog id="alerts-dialog" class="alerts" aria-label="Alerts">
<div class="dialog-head">
<div>
+58 -13
View File
@@ -792,19 +792,6 @@ pre {
cursor: auto;
}
.editor-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 20px 0;
padding: 12px;
background: #eaf3fc;
border-radius: 14px;
}
.editor-nav button {
background: white;
border: 1px solid #c7d9ea;
}
.tile.selected-tile {
border-color: #009fe3;
background: #f0f9ff;
@@ -1157,3 +1144,61 @@ pre {
.alerts td:first-child code { font-size: 14px; }
.copy-line > pre { flex-basis: 100%; }
}
/* Top bar: the name on the left, up to six items on the right. */
.topbar-editor { margin-top: 4px; padding: 16px 18px 12px; border: 1px solid var(--line); border-radius: 16px; background: #f7f9fc; scroll-margin-top: 16px; transition: box-shadow 0.3s; }
.topbar-editor.flash { box-shadow: 0 0 0 3px #9dbcf5; }
.topbar-editor h3 { margin: 0; }
.topbar-fields { display: grid; grid-template-columns: minmax(180px, 2fr) 5fr; gap: 16px; margin-top: 10px; align-items: start; }
.topbar-name { font-size: 13px; }
.topbar-name input { margin-top: 6px; }
.topbar-caption { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.topbar-chips { display: flex; flex-wrap: wrap; gap: 8px; min-height: 46px; }
.topbar-chip { position: relative; display: flex; align-items: center; gap: 8px; max-width: 260px; min-height: 46px; padding: 6px 30px 6px 8px; background: white; border: 1px solid #d5dce5; border-radius: 12px; cursor: grab; user-select: none; touch-action: pan-y; }
.topbar-chip:hover { border-color: #9eb6cf; box-shadow: 0 2px 7px #253f5010; }
.topbar-chip:focus-visible { outline: 3px solid #83b5ff; outline-offset: 2px; }
.topbar-chip .chip-icon { display: grid; place-items: center; width: 30px; height: 30px; flex: none; border-radius: 9px; background: #f0f2f5; color: #46525e; font-size: 19px; }
.chip-text { display: grid; min-width: 0; line-height: 1.25; }
.chip-text strong { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-text small { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-remove { position: absolute; top: 50%; right: 5px; transform: translateY(-50%); width: 22px; height: 22px; padding: 0; border-radius: 50%; background: transparent; color: var(--muted); font-size: 11px; line-height: 1; display: grid; place-items: center; opacity: 0; transition: opacity 0.15s; }
.topbar-chip:hover .chip-remove, .topbar-chip:focus-within .chip-remove { opacity: 1; }
.chip-remove:hover { color: #b3261e; background: #fdecea; }
@media (hover: none) { .chip-remove { opacity: 1; } }
.topbar-chip.is-hidden { background: #fbfcfd; border-style: dashed; }
.topbar-chip.is-hidden .chip-icon, .topbar-chip.is-hidden strong { opacity: 0.55; }
.topbar-chip.is-overflow { border-style: dashed; border-color: #e0b36a; background: repeating-linear-gradient(135deg, #fff 0 8px, #fff6e6 8px 16px); }
.topbar-chip.is-overflow small { color: #9a6200; }
.topbar-chip.dragging-chip { border: 2px dashed var(--accent); background: var(--soft); cursor: grabbing; }
.topbar-chip.just-added { animation: chip-added 1.2s ease-out; }
@keyframes chip-added { from { box-shadow: 0 0 0 4px #9dbcf5; } to { box-shadow: 0 0 0 0 #9dbcf500; } }
.topbar-add { min-height: 46px; padding: 8px 14px; border: 1px dashed #b9c4d0; border-radius: 12px; background: transparent; color: var(--accent); font-size: 13px; }
.topbar-add:hover:not(:disabled) { border-color: var(--accent); background: white; filter: none; }
#topbar-hint { margin: 10px 0 0; }
#topbar-hint.warn { color: #9a6200; }
@media (max-width: 700px) { .topbar-fields { grid-template-columns: 1fr; } .topbar-chip { max-width: 100%; } }
/* The bar on a mockup page: an SVG in the board's own pixels (Roboto and the tile icons, the same
placement rules as the firmware), scaled with the page. */
@font-face { font-family: "Bar Roboto"; src: url("bar-roboto-400.woff") format("woff"); font-weight: 400; font-display: block; }
@font-face { font-family: "Bar Roboto"; src: url("bar-roboto-500.woff") format("woff"); font-weight: 500; font-display: block; }
.preview-bar-wrap { margin: -4px 0 6px; border-radius: 8px; cursor: pointer; }
.preview-bar-wrap:hover { background: #dfe3e8; box-shadow: 0 0 0 5px #dfe3e8; }
.preview-bar { display: block; width: 100%; height: auto; overflow: visible; }
/* Top bar sheet. */
.topbar-options { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
.topbar-option { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: white; color: var(--ink); border: 1px solid var(--line); border-radius: 12px; text-align: left; font-weight: 500; }
.topbar-option:hover:not(:disabled) { border-color: #9eb6cf; filter: none; box-shadow: 0 2px 7px #253f5010; }
.topbar-option .mdi { display: grid; place-items: center; width: 34px; height: 34px; flex: none; border-radius: 9px; background: #f0f2f5; color: #46525e; font-size: 21px; }
.topbar-option > span:last-child { display: grid; min-width: 0; }
.topbar-option strong { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-option small { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-results { display: grid; gap: 6px; max-height: 260px; overflow: auto; margin-top: 8px; }
.topbar-results .topbar-option { width: 100%; }
.topbar-results .hint { margin: 4px 0; }
.topbar-live { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-radius: 12px; background: #e7e7e7; }
.topbar-live small { color: #52606d; font-weight: 500; }
.topbar-sample { display: block; height: auto; max-width: 60%; overflow: visible; }
#topbar-sheet .sheet-head .domain-icon { background: #f0f2f5; color: #46525e; }
.icon-choice.icon-none .mdi { width: 22px; height: 22px; border: 1.5px dashed #b9c4d0; border-radius: 6px; }
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.37"
version: "0.2.38"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen
+78
View File
@@ -0,0 +1,78 @@
#include "components/smart_display/header_bar.h"
#include <cassert>
#include <cstdio>
using namespace header_bar;
int main() {
assert(kind("clock") == Kind::clock && kind("analog") == Kind::analog && kind("date") == Kind::date);
assert(kind("text") == Kind::text && kind("ago") == Kind::ago);
for (const char *bad : {"", "Clock", "tijd", "state"}) assert(kind(bad) == Kind::none);
uint32_t c = 0;
assert(color("FFB300", c) && c == 0xFFB300);
assert(color("43a047", c) && c == 0x43A047);
for (const char *bad : {"", "FFB30", "FFB3000", "#FFB30", "GGGGGG"}) assert(!color(bad, c));
// UTF-8: ASCII, two, three and four bytes; a broken sequence still moves on.
std::string s = "a°—\U000F050F";
size_t i = 0;
assert(next_codepoint(s, i) == 'a' && next_codepoint(s, i) == 0xB0 && next_codepoint(s, i) == 0x2014);
assert(next_codepoint(s, i) == 0xF050F && next_codepoint(s, i) == 0 && i == s.size());
std::string broken = "\xE2" "x";
i = 0;
next_codepoint(broken, i);
assert(next_codepoint(broken, i) == 'x');
// The editor's agoText() uses the same words and thresholds.
const int64_t now = 1789401840;
assert(ago_text(now, 0) == "—" && ago_text(0, now) == "—");
assert(ago_text(now - 59, now) == "Zojuist");
assert(ago_text(now - 60, now) == "1 min geleden");
assert(ago_text(now - 3599, now) == "59 min geleden");
assert(ago_text(now - 3600, now) == "1 uur geleden");
assert(ago_text(now - 86399, now) == "23 uur geleden");
assert(ago_text(now - 86400, now) == "Gisteren");
assert(ago_text(now - 172800, now) == "2 dagen geleden");
assert(ago_text(now - 604800, now) == "1 week geleden");
assert(ago_text(now - 2 * 604800, now) == "2 weken geleden");
assert(ago_text(now - 2592000, now) == "1 maand geleden");
assert(ago_text(now - 3 * 2592000, now) == "3 maanden geleden");
assert(ago_text(now - 31536000, now) == "1 jaar geleden");
assert(ago_text(now + 30, now) == "Over 1 min");
assert(ago_text(now + 600, now) == "Over 10 min");
assert(ago_text(now + 7200, now) == "Over 2 uur");
assert(ago_text(now + 90000, now) == "Morgen");
assert(ago_text(now + 3 * 86400, now) == "Over 3 dagen");
assert(date_text(2, 14, 9) == "ma 14 sep" && date_text(1, 1, 1) == "zo 1 jan" && date_text(7, 31, 12) == "za 31 dec");
assert(date_text(0, 1, 1) == "—" && date_text(1, 1, 13) == "—");
// Guition: digits 15 px high; CYD: 10 px.
auto g = gaps(15);
assert(g.icon == 6 && g.item == 19 && g.name == 24);
auto small = gaps(10);
assert(small.icon == 4 && small.item == 13 && small.name == 16);
assert(gaps(1).icon == 2 && gaps(1).item == 6 && gaps(1).name == 8);
// Everything fits: right-aligned with item gaps, the name keeps the rest.
int widths[] = {80, 50, 49};
auto p = place(widths, 3, g, 448, 100);
assert(p.first == 0 && p.x[2] == 448 - 49 && p.x[1] == p.x[2] - 19 - 50 && p.x[0] == p.x[1] - 19 - 80);
assert(p.name_room == p.x[0] - 24);
// Too full: items leave from the front until the rest fits beside the name.
int many[] = {86, 163, 50, 60};
auto q = place(many, 4, g, 448, 100);
assert(q.first == 1);
int total = 163 + 50 + 60 + 2 * 19;
assert(q.x[1] == 448 - total && q.name_room >= 100);
// A long name keeps 35% of the width, never more than it needs.
auto r = place(many, 4, g, 448, 400);
assert(r.first == 2 && r.name_room >= 448 * 35 / 100);
// Nothing to show: the name gets the whole width.
auto empty = place(many, 0, g, 448, 400);
assert(empty.first == 0 && empty.name_room == 448);
// Items wider than the bar all leave.
int huge[] = {500};
auto none = place(huge, 1, g, 448, 50);
assert(none.first == 1 && none.name_room == 448);
std::puts("test_header_bar: PASS");
}
+303
View File
@@ -0,0 +1,303 @@
"""Top bar (app 0.2.38 / firmware 0.2.32): validation, Dutch text, icons, visibility and delivery."""
import asyncio
import importlib.util
import json
import re
import sys
import tempfile
import unittest
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402
import tile_icons # noqa: E402
from core import FIRMWARE_VERSION, HEADER_MIN_FIRMWARE, discover, header_items, validate_header, validate_layout # noqa: E402
FIRMWARE = (ROOT / 'components/smart_display/header_bar.h').read_text()
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
EDITOR = (ROOT / 'screen_manager/app/static/app.js').read_text()
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml')
def state(value, last_changed='2026-09-13T14:00:00+00:00', **attributes):
return {'state': value, 'attributes': attributes, 'last_changed': last_changed}
def entity(eid, content='state', icon='auto', show='always'):
return {'type': 'entity', 'entity': eid, 'content': content, 'icon': icon, 'show': show}
class ValidationTests(unittest.TestCase):
def test_items_fill_defaults_and_keep_order(self):
header = validate_header({'items': [{'type': 'clock'}, {'type': 'entity', 'entity': 'binary_sensor.voordeur'}, {'type': 'analog'}]})
self.assertEqual(header['items'], [{'type': 'clock'}, entity('binary_sensor.voordeur'), {'type': 'analog'}])
# Top-bar-only domains: a phone's tracker, a lock, the alarm, the home zone.
for eid in ('device_tracker.telefoon', 'lock.voordeur', 'alarm_control_panel.huis', 'zone.home', 'event.deurbel'):
validate_header({'items': [entity(eid)]})
def test_rejects_what_the_firmware_cannot_draw(self):
bad = [
None, [], {'items': None}, {'items': [], 'extra': 1},
{'items': [{'type': 'clock'}] * 7},
{'items': [{'type': 'weather'}]},
{'items': [{'type': 'clock', 'entity': 'sensor.x'}]},
{'items': [entity('screen.clock')]},
{'items': [entity('update.home_assistant')]},
{'items': [entity('sensor.x', content='attribute')]},
{'items': [entity('sensor.x', show='sometimes')]},
{'items': [entity('sensor.x', icon='mdi:thermometer')]},
{'items': [{**entity('sensor.x'), 'color': 'red'}]},
{'items': [entity('sensor.x'), entity('sensor.x')]},
{'items': [{'type': 'date'}, {'type': 'date'}]},
]
for data in bad:
with self.assertRaises(ValueError, msg=data):
validate_header(data)
# The same entity twice is fine when it shows something else.
validate_header({'items': [entity('person.max'), entity('person.max', content='last_changed')]})
def test_layout_keeps_header_and_old_layouts_keep_their_clock(self):
layout = validate_layout({'title': 'Woonkamer', 'tiles': [], 'header': {'items': [{'type': 'date'}]}})
self.assertEqual(header_items(layout), [{'type': 'date'}])
self.assertEqual(header_items(validate_layout({'title': 'Thuis', 'tiles': []})), [{'type': 'clock'}])
without_clock = validate_layout({'title': 'Thuis', 'tiles': [], 'settings': {'show_clock': False}})
self.assertEqual(header_items(without_clock), [])
class TextTests(unittest.TestCase):
def test_numbers_as_home_assistant_writes_them_in_dutch(self):
self.assertEqual(header_bar.number_text(21.34, 1), '21,3')
self.assertEqual(header_bar.number_text(1249.0, 0), '1.249')
self.assertEqual(header_bar.number_text(1249.04, 1), '1.249,0')
self.assertEqual(header_bar.number_text(234.038566388889), '234,039')
self.assertEqual(header_bar.number_text(23.5), '23,5')
self.assertEqual(header_bar.number_text(-3.5, 1), '-3,5')
self.assertEqual(header_bar.number_text(-0.04, 1), '0,0')
self.assertEqual(header_bar.with_unit('21,3', '°C'), '21,3 °C')
self.assertEqual(header_bar.with_unit('65', '%'), '65%')
self.assertEqual(header_bar.with_unit('18', '°'), '18°')
self.assertEqual(header_bar.with_unit('4', None), '4')
def test_values_per_domain(self):
tz = ZoneInfo('Europe/Amsterdam')
registry = {'sensor.t': {'options': {'sensor': {'suggested_display_precision': 1}}},
'sensor.p': {'options': {'sensor': {'suggested_display_precision': 2, 'display_precision': 0}}}}
cases = [
('sensor.t', state('21.34', unit_of_measurement='°C', device_class='temperature'), ('21,3 °C', None)),
('sensor.p', state('1249.4', unit_of_measurement='W'), ('1.249 W', None)),
('sensor.h', state('unavailable', unit_of_measurement='%'), ('—', None)),
('binary_sensor.deur', state('on', device_class='door'), ('Open', None)),
('binary_sensor.deur', state('off', device_class='door'), ('Dicht', None)),
('binary_sensor.rook', state('on', device_class='smoke'), ('Rook', None)),
('binary_sensor.iets', state('off'), ('Uit', None)),
('alarm_control_panel.huis', state('armed_away'), ('Afwezig', None)),
('alarm_control_panel.huis', state('disarmed'), ('Uit', None)),
('alarm_control_panel.huis', state('triggered'), ('Alarm', None)),
('lock.voordeur', state('locked'), ('Op slot', None)),
('person.max', state('not_home'), ('Weg', None)),
('person.max', state('Kantoor'), ('Kantoor', None)),
('zone.home', state('4'), ('4', None)),
('weather.thuis', state('rainy', temperature=17.6, temperature_unit='°C'), ('18 °C', None)),
('climate.woonkamer', state('heat', current_temperature=20.46), ('20,5 °C', None)),
('sun.sun', state('above_horizon', next_setting='2026-09-14T17:57:31+00:00'), ('19:57', None)),
('input_datetime.wekker', state('07:30:00', has_date=False, has_time=True), ('07:30', None)),
('sensor.backup', state('2026-09-15T03:34:32+00:00', device_class='timestamp'), (None, 1789443272)),
('scene.film', state('2026-09-14T15:32:14+00:00'), (None, 1789399934)),
('script.alles_uit', state('off', last_triggered='2026-09-14T15:32:14+00:00'), (None, 1789399934)),
]
for eid, value, expected in cases:
self.assertEqual(header_bar.value(eid, value, registry.get(eid), {'temperature': '°C'}, tz), expected, eid)
def test_text_keeps_to_the_font_and_its_length(self):
self.assertEqual(header_bar.clean_text('Café “Zon” 25 m³'), 'Café Zon 25 m³')
self.assertEqual(header_bar.clean_text('Ångström ✓'), 'Angström')
self.assertLessEqual(len(header_bar.clean_text('é' * 60).encode()), header_bar.TEXT_BYTES)
def test_active_colour_and_icon(self):
door_open, door_closed = state('on', device_class='door'), state('off', device_class='door')
self.assertTrue(header_bar.active('binary_sensor.deur', door_open))
self.assertFalse(header_bar.active('binary_sensor.deur', door_closed))
self.assertFalse(header_bar.active('sensor.p', state('0.0')))
self.assertTrue(header_bar.active('sensor.p', state('12')))
self.assertTrue(header_bar.active('person.max', state('home')))
self.assertFalse(header_bar.active('person.max', state('Kantoor')))
self.assertFalse(header_bar.active('lock.voordeur', state('locked')))
self.assertFalse(header_bar.active('sensor.x', state('unavailable')))
self.assertEqual(header_bar.accent('binary_sensor.deur', door_open), header_bar.AMBER)
self.assertIsNone(header_bar.accent('binary_sensor.deur', door_closed))
self.assertEqual(header_bar.accent('binary_sensor.lek', state('on', device_class='moisture')), header_bar.RED)
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('armed_home')), header_bar.GREEN)
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('arming')), header_bar.ORANGE)
self.assertEqual(header_bar.accent('alarm_control_panel.huis', state('triggered')), header_bar.RED)
self.assertIsNone(header_bar.accent('alarm_control_panel.huis', state('disarmed')))
self.assertEqual(header_bar.accent('lock.voordeur', state('unlocked')), header_bar.RED)
self.assertEqual(header_bar.accent('person.max', state('home')), header_bar.GREEN)
glyph = tile_icons.GLYPHS
self.assertEqual(header_bar.auto_icon('binary_sensor.deur', door_open), glyph['door-open'])
self.assertEqual(header_bar.auto_icon('binary_sensor.deur', door_closed), glyph['door-closed'])
self.assertEqual(header_bar.auto_icon('sensor.t', state('21', device_class='temperature')), glyph['thermometer'])
self.assertEqual(header_bar.auto_icon('sensor.t', state('21', device_class='temperature', icon='mdi:fire')), glyph['fire'])
self.assertEqual(header_bar.auto_icon('weather.thuis', state('rainy')), glyph['weather-rainy'])
self.assertEqual(header_bar.auto_icon('lock.voordeur', state('unlocked')), glyph['lock-open-variant'])
self.assertEqual(header_bar.auto_icon('counter.bezoekers', None), glyph['gauge'])
def test_every_icon_name_is_in_the_fonts(self):
names = set(header_bar.SENSOR_ICONS.values()) | set(header_bar.DOMAIN_ICONS.values())
names |= {name for pair in header_bar.BINARY_ICONS.values() for name in pair}
self.assertLessEqual(names, set(tile_icons.GLYPHS))
self.assertIn('F0150', tile_icons.GLYPHS.values(), 'the dial is sized by clock-outline')
self.assertIn('0xF0150', TILES)
class MessageTests(unittest.TestCase):
def test_message_leaves_hidden_items_out_and_keeps_builtins(self):
states = {'binary_sensor.deur': state('off', device_class='door'), 'person.max': state('home', last_changed='2026-09-14T15:00:00+00:00'),
'sensor.t': state('21.3', unit_of_measurement='°C', device_class='temperature')}
layout = {'title': 'Hal', 'tiles': [], 'header': {'items': [
entity('binary_sensor.deur', show='active'), entity('person.max', content='last_changed'),
entity('sensor.t', icon='none'), {'type': 'analog'}, {'type': 'clock'}]}}
message = header_bar.message(layout, states)
self.assertEqual(message['op'], 'header')
self.assertEqual(message['items'], [
{'k': 'ago', 'i': tile_icons.GLYPHS['account'], 'e': 1789398000, 'c': header_bar.GREEN},
{'k': 'text', 't': '21,3 °C'}, {'k': 'analog'}, {'k': 'clock'}])
states['binary_sensor.deur'] = state('on', device_class='door')
opened = header_bar.message(layout, states)['items'][0]
self.assertEqual(opened, {'k': 'text', 'i': tile_icons.GLYPHS['door-open'], 't': 'Open', 'c': header_bar.AMBER})
preview = header_bar.preview(layout['header'], {**states, 'binary_sensor.deur': state('off', device_class='door')})
self.assertEqual([p['shown'] for p in preview], [False, True, True, True, True])
self.assertEqual(preview[0]['name'], 'binary_sensor.deur')
def test_old_layout_sends_the_clock_of_show_clock(self):
self.assertEqual(header_bar.message({'title': 'Thuis', 'tiles': []}, {})['items'], [{'k': 'clock'}])
self.assertEqual(header_bar.message({'title': 'Thuis', 'tiles': [], 'settings': {'show_clock': False}}, {})['items'], [])
def test_suggestions_prefer_the_screens_room_and_skip_diagnostics(self):
states = {'sensor.plug_temp': state('30', device_class='temperature'), 'sensor.woonkamer_temp': state('21', device_class='temperature'),
'sensor.zolder_temp': state('18', device_class='temperature'), 'zone.home': state('3'), 'sun.sun': state('above_horizon')}
entities = [{'id': 'sensor.plug_temp', 'name': 'Plug', 'area': 'Woonkamer'}, {'id': 'sensor.woonkamer_temp', 'name': 'Woonkamer', 'area': 'Woonkamer'},
{'id': 'sensor.zolder_temp', 'name': 'Zolder', 'area': 'Zolder'}, {'id': 'zone.home', 'name': 'Thuis', 'area': ''},
{'id': 'sun.sun', 'name': 'Zon', 'area': ''}]
picks = header_bar.suggestions({'name': 'Woonkamer'}, entities, states, {'sensor.plug_temp': {'entity_category': 'diagnostic'}})
self.assertEqual([(p['label'], p['item']['entity']) for p in picks],
[('Temperatuur', 'sensor.woonkamer_temp'), ('Aantal thuis', 'zone.home'), ('Zon op en onder', 'sun.sun')])
for pick in picks:
validate_header({'items': [pick['item']]})
def test_discovery_offers_top_bar_domains_outside_the_tile_picker(self):
registry = [{'entity_id': 'lock.voordeur'}, {'entity_id': 'light.hal'}, {'entity_id': 'update.core'}]
states = {'lock.voordeur': state('locked'), 'light.hal': state('on'), 'update.core': state('off'), 'zone.home': state('2')}
_, entities = discover(registry, states, [], [])
flags = {e['id']: e.get('tile', True) for e in entities}
self.assertEqual(flags, {'lock.voordeur': False, 'light.hal': True, 'zone.home': False})
class ParityTests(unittest.TestCase):
def test_text_font_carries_exactly_the_glyphs_the_add_on_sends(self):
for name in PROFILES + ('packages/cyd.yaml', 'packages/guition.yaml'):
text = (ROOT / name).read_text()
block = re.search(r'id: sublabel_big\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S)
self.assertIsNotNone(block, name)
glyphs = set(re.findall(r"'([^'])'|\"(')\"", block.group(1)))
self.assertEqual({a or b for a, b in glyphs}, set(header_bar.GLYPHS), name)
def test_editor_and_firmware_share_words_and_spacing(self):
for phrase in ('Zojuist', ' min geleden', ' uur geleden', 'Gisteren', ' dagen geleden', '1 week geleden', ' weken geleden',
'1 maand geleden', ' maanden geleden', ' jaar geleden', 'Over ', 'Morgen'):
self.assertIn(phrase, FIRMWARE)
self.assertIn(phrase, EDITOR)
for threshold in ('3600', '86400', '172800', '604800', '2592000', '31536000'):
self.assertIn(threshold, FIRMWARE)
self.assertIn(threshold, EDITOR)
self.assertIn('std::max(2, scaled(cap, 4)), std::max(6, (cap * 125 + 50) / 100), std::max(8, scaled(cap, 16))', FIRMWARE)
self.assertIn('Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10))', EDITOR)
self.assertIn('width * 35 / 100', FIRMWARE)
self.assertIn('(metrics.width * 35) / 100', EDITOR)
self.assertEqual(header_bar.MONTHS, tuple(re.search(r'const MONTHS = \[(.*?)\];', EDITOR).group(1).replace('"', '').replace(' ', '').split(',')))
def test_profiles_draw_the_bar_from_the_runtime(self):
for name in PROFILES:
text = (ROOT / name).read_text()
self.assertIn('runtime_tiles::header_text_font = id(sublabel_big)->get_lv_font();', text, name)
self.assertIn('runtime_tiles::header_icon_font = id(materialdesign_icons_mini)->get_lv_font();', text, name)
self.assertIn('runtime_tiles::time_label = id(lbl_time);', text, name)
refresh = text.split(' - id: ui_refresh', 1)[1].split('return;', 1)[0]
self.assertNotIn('lbl_time', refresh, name)
self.assertIn('render_header();', TILES.split('inline void render(lv_obj_t *room) {', 1)[1].split('\n', 5)[4])
self.assertLessEqual(HEADER_MIN_FIRMWARE, tuple(int(p) for p in FIRMWARE_VERSION.split('.')))
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class ManagerTests(unittest.IsolatedAsyncioTestCase):
def manager(self, path, firmware='0.2.32'):
class HA:
online = True
registry = [{'entity_id': 'text.screen', 'platform': 'esphome', 'original_name': 'Tegelinstellingen', 'device_id': 'd'},
{'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware', 'device_id': 'd'},
{'entity_id': 'sensor.t', 'options': {'sensor': {'suggested_display_precision': 1}}},
{'entity_id': 'binary_sensor.deur'}, {'entity_id': 'light.a'}]
devices, areas = [], []
time_zone, units = None, {'temperature': '°C'}
def __init__(self):
self.messages = []
self.changed = asyncio.Event()
self.states = {'text.screen': state('Ready'), 'sensor.fw': state(firmware), 'light.a': state('on'),
'sensor.t': state('21.34', unit_of_measurement='°C', device_class='temperature'),
'binary_sensor.deur': state('off', device_class='door')}
async def send(self, inbox, message):
self.messages.append(message)
return Manager(HA(), path)
async def test_header_follows_layout_only_on_new_firmware(self):
with tempfile.TemporaryDirectory() as temp:
m = self.manager(Path(temp) / 'screens.json')
layout = {'title': 'Hal', 'tiles': [{'entity': 'light.a', 'name': ''}], 'settings': {'show_clock': True},
'header': {'items': [entity('sensor.t'), entity('binary_sensor.deur', show='active')]}}
m.save('text.screen', layout)
saved = m.layouts['text.screen']
self.assertFalse(saved['settings']['show_clock'], 'older firmware hides its clock when the bar has none')
await m.sync_one('text.screen', saved)
self.assertEqual([msg['op'] for msg in m.ha.messages], ['layout', 'header', 'state'])
self.assertEqual(m.ha.messages[1]['items'], [{'k': 'text', 'i': tile_icons.GLYPHS['thermometer'], 't': '21,3 °C'}])
await m.sync_one('text.screen', saved)
self.assertEqual(len(m.ha.messages), 3, 'an unchanged bar is not sent again')
m.ha.states['binary_sensor.deur'] = state('on', device_class='door')
await m.sync_one('text.screen', saved)
self.assertEqual(len(m.ha.messages), 4)
self.assertEqual(m.ha.messages[-1]['items'][1]['t'], 'Open')
self.assertIn('binary_sensor.deur', m.watched_entities())
old = self.manager(Path(temp) / 'old.json', firmware='0.2.31')
old.save('text.screen', layout)
await old.sync_one('text.screen', old.layouts['text.screen'])
self.assertEqual([msg['op'] for msg in old.ha.messages], ['layout', 'state'])
async def test_older_editor_keeps_the_bar_and_gone_entities_are_refused(self):
with tempfile.TemporaryDirectory() as temp:
m = self.manager(Path(temp) / 'screens.json')
header = {'items': [{'type': 'date'}, {'type': 'clock'}]}
m.save('text.screen', {'title': 'Hal', 'tiles': [], 'header': header})
m.save('text.screen', {'title': 'Andere naam', 'tiles': []})
self.assertEqual(m.layouts['text.screen']['header'], header)
with self.assertRaises(ValueError):
m.save('text.screen', {'title': 'Hal', 'tiles': [], 'header': {'items': [entity('sensor.bestaat_niet')]}})
self.assertEqual(m.layouts['text.screen']['header'], header)
async def test_preview_endpoint_formats_unsaved_items(self):
with tempfile.TemporaryDirectory() as temp:
m = self.manager(Path(temp) / 'screens.json')
app = create_app(m, development=True)
async with TestClient(TestServer(app)) as client:
inventory = await (await client.get('/api/inventory')).json()
self.assertEqual(inventory['header']['max_items'], 6)
self.assertEqual(inventory['header']['min_firmware'], '0.2.32')
headers = {'X-Screen-CSRF': inventory['csrf']}
items = [entity('binary_sensor.deur', show='active'), {'type': 'clock'}]
response = await client.post('/api/header-preview', json={'header': {'items': items}}, headers=headers)
data = await response.json()
self.assertEqual([p['shown'] for p in data['items']], [False, True])
self.assertEqual(data['items'][0]['t'], 'Dicht')
bad = await client.post('/api/header-preview', json={'header': {'items': [{'type': 'nope'}]}}, headers=headers)
self.assertEqual(bad.status, 400)
if __name__ == '__main__':
unittest.main()
+27 -4
View File
@@ -1,11 +1,12 @@
"""Write the tile icon set into both board profiles and build the editor's icon font.
"""Write the tile icon set into both board profiles and build the editor's fonts.
The list lives in screen_manager/app/tile_icons.py. Every Material Design Icons
font of a board profile gets exactly those glyphs (the first font carries the
list as a YAML anchor, the smaller sizes reuse it), and the editor gets a subset
of the same TTF so its mockup shows the glyphs the screen draws. Run
tools/generate_packages.py afterwards; --check changes nothing and fails when
anything is out of date.
of the same TTF so its mockup shows the glyphs the screen draws. The top bar
mockup also gets Roboto 400/500 with the glyphs of header_bar.GLYPHS, so text
widths in the editor match the screen. Run tools/generate_packages.py afterwards;
--check changes nothing and fails when anything is out of date.
"""
import argparse
import io
@@ -18,11 +19,13 @@ from fontTools.ttLib import TTFont
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import header_bar # noqa: E402
import tile_icons # noqa: E402
TTF = ROOT / 'fonts/materialdesignicons-webfont.ttf'
PROFILES = [ROOT / 'home-like-2432s028.yaml', ROOT / 'guition-4848s040.yaml']
WEB_FONT = ROOT / 'screen_manager/app/static/tile-icons.woff'
BAR_FONTS = {weight: ROOT / f'screen_manager/app/static/bar-roboto-{weight}.woff' for weight in (400, 500)}
NAME_TABLE = ROOT / 'components/smart_display/tile_icon_names.h'
FONT_BLOCK = re.compile(r"( - file: 'fonts/materialdesignicons-webfont\.ttf'\n id: \w+\n size: \d+\n) glyphs:.*\n(?: .*\n)*")
@@ -103,6 +106,21 @@ def web_font():
font.save(buffer)
return buffer.getvalue()
def bar_font(weight):
"""Roboto subset for the editor's top bar mockup: the glyphs and metrics the screens draw."""
font = TTFont(ROOT / f'fonts/Roboto-{weight}.ttf')
options = subset.Options()
options.flavor = 'woff'
options.layout_features = ['kern']
options.name_IDs = [0, 1, 2]
subsetter = subset.Subsetter(options)
subsetter.populate(unicodes=[ord(char) for char in header_bar.GLYPHS])
subsetter.subset(font)
buffer = io.BytesIO()
font.flavor = 'woff'
font.save(buffer)
return buffer.getvalue()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
@@ -120,9 +138,14 @@ def main():
wanted = {int(code, 16) for code in tile_icons.GLYPHS.values()}
if not WEB_FONT.exists() or set(TTFont(WEB_FONT).getBestCmap()) != wanted:
raise SystemExit(f'Outdated: {WEB_FONT.name}')
for weight, path in BAR_FONTS.items():
if not path.exists() or set(TTFont(path).getBestCmap()) != {ord(char) for char in header_bar.GLYPHS}:
raise SystemExit(f'Outdated: {path.name}')
else:
NAME_TABLE.write_text(name_table())
WEB_FONT.write_bytes(web_font())
for weight, path in BAR_FONTS.items():
path.write_bytes(bar_font(weight))
print(f'{len(tile_icons.ICONS)} pickable icons, {len(tile_icons.GLYPHS)} glyphs: ' + ('verified' if args.check else 'generated'))
if __name__ == '__main__':
+299
View File
@@ -0,0 +1,299 @@
"""Render the real top bar code (runtime_tiles::draw_header) for both boards on this computer.
Builds a small ESPHome host project (SDL2 display, LVGL 9.5 as the screens run it) with the
fonts copied verbatim from both board profiles, draws a set of scenarios through the firmware's
own drawing code and saves LVGL snapshots as PNG plus one contact sheet: Guition at 2x, CYD at
3x, nearest neighbour, so every pixel shows. No screen, Home Assistant or flash is involved.
Needs the ESPHome CLI (2026.6.2) and SDL2 (`sdl2-config` on PATH, e.g. `brew install sdl2`).
Output: .esphome/render-topbar/out/*.png and sheet.png (or --out).
"""
import argparse
import os
import re
import shutil
import struct
import subprocess
import sys
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WORK = ROOT / '.esphome' / 'render-topbar'
PROFILES = {'guition': 'guition-4848s040.yaml', 'cyd': 'home-like-2432s028.yaml'}
FONTS = ('headline', 'time_label', 'sublabel_big', 'label', 'materialdesign_icons', 'materialdesign_icons_mini')
# Hardware headers the host cannot compile; the top bar needs none of them.
SKIP = {'backlight_fade.h', 'guition_diagnostics.h', '__pycache__'}
# Header band per board: page width and height above the tiles.
BANDS = {'guition': (480, 72), 'cyd': (320, 40)}
# (slug, title, items) with items as C++ expressions of the render lambda's helpers.
SCENARIOS = (
('1-klok', 'Studio 1', ['builtin(Kind::clock)']),
('2-mockup', 'Woonkamer', ['text(0xF050F, "—")', 'text(0xF050F, "21,3 °C")', 'text(0xF058E, "—")', 'ago(0xF0004, 26 * 3600)']),
('3-deur-alarm', 'Studio 1', ['text(0xF081C, "Open", 0xFFB300)', 'text(0xF068A, "Afwezig", 0x43A047)', 'builtin(Kind::clock)']),
('4-analoog-datum', 'Keuken', ['text(0xF050F, "23,5 °C")', 'builtin(Kind::date)', 'builtin(Kind::analog)']),
('5-thuis-weer-stroom', 'Studio 1', ['text(0xF0849, "4")', 'text(0xF0595, "22 °C")', 'text(0xF0241, "1.249 W")', 'builtin(Kind::clock)']),
('6-te-vol', 'Woonkamer beneden', ['text(0xF050F, "21,3 °C")', 'text(0xF058E, "48%")', 'ago(0xF0D91, 5 * 60)', 'text(0xF0241, "78,0 W")', 'builtin(Kind::analog)', 'builtin(Kind::clock)']),
('7-mensen', 'Studio 1', ['text(0xF0004, "Weg")', 'text(0xF0849, "4")', 'builtin(Kind::analog)', 'builtin(Kind::clock)']),
)
def font_blocks(board):
"""The profile's font entries, ids suffixed per board and file paths made absolute."""
text = (ROOT / PROFILES[board]).read_text()
fonts = text[text.index('\nfont:\n'):]
out = ''
for font_id in FONTS:
m = re.search(r'\n - file: [^\n]+\n id: ' + font_id + r'\n(?: [^\n]*\n| [^\n]*\n| [^\n]*\n)*', fonts)
if not m:
raise SystemExit(f'Font {font_id} not found in {PROFILES[board]}')
block = m.group(0).replace(f'id: {font_id}\n', f'id: {font_id}_{board}\n')
block = re.sub(r'file: ["\']fonts/([^"\']+)["\']', lambda f: f'file: "{ROOT}/fonts/{f[1]}"', block)
out += block.replace('&tile_icons', f'&tile_icons_{board}').replace('*tile_icons\n', f'*tile_icons_{board}\n')
return out
def band(board, y):
width, height = BANDS[board]
margin, top = (16, 22) if board == 'guition' else (11, 14)
tile_y, tile_w, tile_h, col2, radius = (72, 218, 108, 246, 22) if board == 'guition' else (40, 147, 52, 164, 18)
return f''' - obj:
id: {board}_page
x: 0
y: {y}
width: {width}
height: {height + tile_h + margin}
bg_color: 0xE7E7E7
bg_opa: COVER
border_width: 0
radius: 0
pad_all: 0
scrollable: false
widgets:
- label:
id: {board}_room
x: {margin}
y: {top}
text_font: headline_{board}
text_color: 0x1B1B1B
- label:
id: {board}_time
align: TOP_RIGHT
x: -{margin}
y: {top}
text: ""
text_font: time_label_{board}
- obj:
x: {margin if board == 'guition' else 9}
y: {tile_y}
width: {tile_w}
height: {tile_h}
radius: {radius}
border_width: 1
border_color: 0xDDDDDD
- obj:
x: {col2}
y: {tile_y}
width: {tile_w}
height: {tile_h}
radius: {radius}
border_width: 1
border_color: 0xDDDDDD
'''
def render_lambda(out):
scenarios = ',\n'.join(f' {{"{slug}", "{title}", {{{", ".join(items)}}}}}' for slug, title, items in SCENARIOS)
return f'''using namespace runtime_tiles;
using header_bar::Kind;
static const std::string OUT = "{out}/";
// A fixed moment, 14 september 2026 18:04 in the Netherlands, so renders compare between runs.
static const int64_t NOW = 1789401840;
auto fixed = esphome::ESPTime::from_epoch_local(NOW);
now_time = [fixed]() {{ return fixed; }};
enabled = true;
screen_settings::current.clock_24h = 1;
auto text = [](uint32_t icon, const char *value, uint32_t color = 0) {{
header_bar::Item item; item.kind = Kind::text; item.icon = icon; item.text = value;
if (color) {{ item.color = color; item.has_color = true; }}
return item;
}};
auto ago = [](uint32_t icon, int64_t seconds) {{ header_bar::Item item; item.kind = Kind::ago; item.icon = icon; item.epoch = NOW - seconds; return item; }};
auto builtin = [](Kind kind) {{ header_bar::Item item; item.kind = kind; return item; }};
struct Scenario {{ const char *slug, *title; std::vector<header_bar::Item> items; }};
std::vector<Scenario> scenarios = {{
{scenarios}
}};
struct Board {{ const char *name; lv_obj_t *page, *room, *time; const lv_font_t *text_font, *icon_font; }};
std::vector<Board> boards = {{
{{"guition", id(guition_page), id(guition_room), id(guition_time), id(sublabel_big_guition)->get_lv_font(), id(materialdesign_icons_mini_guition)->get_lv_font()}},
{{"cyd", id(cyd_page), id(cyd_room), id(cyd_time), id(sublabel_big_cyd)->get_lv_font(), id(materialdesign_icons_mini_cyd)->get_lv_font()}},
}};
for (auto &scenario : scenarios) {{
for (auto &board : boards) {{
// A fresh bar per board: the runtime keeps one set of objects.
if (header_root) {{ lv_obj_delete(header_root); header_root = nullptr; header_ring = nullptr; header_hands = {{}}; header_slots = {{}}; header_dial_key = -1; }}
room_label = board.room; time_label = board.time; header_text_font = board.text_font; header_icon_font = board.icon_font;
lv_label_set_long_mode(board.room, LV_LABEL_LONG_WRAP);
lv_obj_set_size(board.room, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_label_set_text(board.room, scenario.title);
header = header_bar::Bar{{}};
for (auto &item : scenario.items) header.items[header.count++] = item;
header.received = true;
for (int pass = 0; pass < 2; ++pass) {{ lv_obj_update_layout(board.page); draw_header(true); }}
lv_obj_update_layout(board.page);
lv_draw_buf_t *buf = lv_snapshot_take(board.page, LV_COLOR_FORMAT_RGB888);
std::string path = OUT + scenario.slug + "-" + board.name + ".ppm";
FILE *f = fopen(path.c_str(), "wb");
fprintf(f, "P6\\n%d %d\\n255\\n", (int)buf->header.w, (int)buf->header.h);
for (int y = 0; y < (int)buf->header.h; ++y) {{
const uint8_t *row = buf->data + y * buf->header.stride;
for (int x = 0; x < (int)buf->header.w; ++x) {{ uint8_t px[3] = {{row[x * 3 + 2], row[x * 3 + 1], row[x * 3]}}; fwrite(px, 1, 3, f); }}
}}
fclose(f);
lv_draw_buf_destroy(buf);
}}
}}
ESP_LOGI("render", "klaar");
fflush(stdout);
exit(0);
'''
def project(out):
lam = '\n'.join((' ' + line) if line else '' for line in render_lambda(out).splitlines())
return f'''esphome:
name: render-topbar
platformio_options:
build_flags:
- -DLV_USE_SNAPSHOT=1
on_boot:
priority: -100
then:
- lambda: |-
{lam}
host:
external_components:
- source:
type: local
path: {WORK}/components
components: [smart_display]
logger:
level: INFO
# Sets the process time zone the way the screens get it; the renders use a fixed moment.
time:
- platform: host
timezone: Europe/Amsterdam
api:
homeassistant_services: true
reboot_timeout: 0s
text:
- platform: smart_display
name: "Tegelinstellingen"
display:
- platform: sdl
id: sdl_display
dimensions:
width: 480
height: 480
auto_clear_enabled: false
update_interval: never
font:{font_blocks('guition')}{font_blocks('cyd')}
lvgl:
displays: sdl_display
pages:
- id: render_page
pad_all: 0
scrollable: false
widgets:
# Never shown: they make ESPHome compile the widgets the component code creates.
- slider:
hidden: true
- switch:
hidden: true
- spinner:
hidden: true
spin_time: 1s
arc_length: 60deg
- line:
hidden: true
points:
- 0, 0
- 1, 1
{band('guition', 0)}{band('cyd', 220)}
script:
- id: action_seed
then:
- homeassistant.action:
action: light.turn_on
'''
def png(path, width, height, rgb):
raw = b''.join(b'\0' + rgb[y * width * 3:(y + 1) * width * 3] for y in range(height))
chunk = lambda kind, data: struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xFFFFFFFF)
path.write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raw, 9)) + chunk(b'IEND', b''))
def read_ppm(path):
data = path.read_bytes()
magic, width, height, depth, pixels = re.match(rb'(P6)\s+(\d+)\s+(\d+)\s+(\d+)\s(.*)', data, re.S).groups()
return int(width), int(height), pixels
def crop_scale(image, width, height, scale):
w, _, rgb = image
out = bytearray()
for y in range(height * scale):
row = rgb[(y // scale) * w * 3:(y // scale) * w * 3 + width * 3]
out += b''.join(row[x * 3:x * 3 + 3] * scale for x in range(width))
return bytes(out)
def sheet(out):
"""Header bands of every scenario: Guition 2x above CYD 3x, 12 px white between."""
width, gap = 960, 12
rows = []
for slug, _, _ in SCENARIOS:
for board, scale in (('guition', 2), ('cyd', 3)):
image = read_ppm(out / f'{slug}-{board}.ppm')
band_w, band_h = BANDS[board]
png(out / f'{slug}-{board}.png', image[0], image[1], image[2])
rows.append((crop_scale(image, band_w, band_h, scale), band_h * scale))
rows.append((bytes([255]) * width * 3 * gap * 2, gap * 2))
height = sum(h for _, h in rows)
png(out / 'sheet.png', width, height, b''.join(data for data, _ in rows))
return out / 'sheet.png'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--out', type=Path, default=WORK / 'out')
parser.add_argument('--esphome', default=os.environ.get('ESPHOME') or shutil.which('esphome') or str(Path.home() / '.local/pipx/venvs/esphome/bin/esphome'))
args = parser.parse_args()
if not shutil.which('sdl2-config'):
raise SystemExit('SDL2 ontbreekt: installeer het eerst (bijvoorbeeld brew install sdl2).')
out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True)
for old in out.glob('*.p[np][gm]'):
old.unlink()
components = WORK / 'components' / 'smart_display'
shutil.rmtree(components, ignore_errors=True)
components.mkdir(parents=True)
for source in (ROOT / 'components/smart_display').iterdir():
if source.name not in SKIP:
(components / source.name).symlink_to(source)
config = WORK / 'render-topbar.yaml'
config.write_text(project(out))
build = subprocess.run([args.esphome, 'compile', str(config)], capture_output=True, text=True)
if build.returncode:
sys.stdout.write(build.stdout[-4000:] + build.stderr[-2000:])
raise SystemExit('Build mislukt.')
program = next(WORK.glob('.esphome/build/render-topbar/.pioenvs/render-topbar/program'), None)
if not program:
raise SystemExit('Geen host-programma gevonden na de build.')
subprocess.run([str(program)], capture_output=True, timeout=60, check=True)
print(sheet(out))
if __name__ == '__main__':
main()