Add clock, forecast, graph, sun, timer and person cards with wide tiles

Any tile can now be double width: pack() in runtime_model.h places wide
tiles in the left column and the manager preview follows the same rules.
New cards: built-in clock (digital or analog, screen.clock), five-day
weather forecast on a wide weather card fed by weather.get_forecasts,
history sparkline for sensors, sunrise/sunset (sun.sun), live kitchen
timer (timer.*, tap starts or pauses) and presence (person.*). The
manager pre-computes time-zone and forecast extras in a new 'x' object
and refuses layouts with new domains until the screen runs 0.2.14.

The self-test follows the packing, checks custom card parts and wide
widths, and diagnostics/send_layout.py pushes a demo layout of every
card type for render checks without Home Assistant. App 0.2.12,
firmware 0.2.14.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 02:09:50 +02:00
parent 53bf1bc228
commit 517b102786
21 changed files with 1185 additions and 146 deletions
+89 -45
View File
@@ -220,7 +220,31 @@ const domains = {
select: ["Keuze", "≡", "#5862af", "#eaecfa"],
input_select: ["Keuze", "≡", "#5862af", "#eaecfa"],
button: ["Actie", "↗", "#5862af", "#eaecfa"],
screen: ["Klok", "◷", "#25282c", "#e9ecf1"],
sun: ["Zon", "☼", "#c86620", "#ffebdc"],
timer: ["Kookwekker", "⏱", "#008577", "#def3ed"],
person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"],
};
const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok" };
// Same packing as the firmware: wide tiles start in the left column and take a whole row.
function packTiles(tiles) {
let position = 0;
const placement = tiles.map((tile) => {
const wide = tile.options?.size === "wide";
if (wide && position % 2 === 1) position++;
const slot = { page: Math.floor(position / 6), slot: position % 6, wide };
position += wide ? 2 : 1;
return slot;
});
return { placement, pages: Math.max(1, Math.ceil(position / 6)) };
}
function supportsFirmware(major, minor, patch) {
const version = inventory.screens.find((s) => s.id === selected)?.firmware || "";
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) return false;
const [a, b, c] = match.slice(1).map(Number);
return a > major || (a === major && (b > minor || (b === minor && c >= patch)));
}
function domainBadge(id) {
const [title, symbol, color, background] = domains[id.split(".")[0]] || ["Entiteit", "◇", "#637184", "#edf0f4"];
const badge = node("span", symbol, "domain-icon");
@@ -236,43 +260,54 @@ function tileLimit() {
if(!match)return 10;
return Number(match[1])>0 || Number(match[2])>2 || Number(match[2])===2 && Number(match[3])>=7 ? 20 : 10;
}
function entityName(id) {
return inventory.entities.find((e) => e.id === id)?.name || inventory.builtin?.find((e) => e.id === id)?.name || id;
}
function renderPreview() {
const root = $("#layout-preview");
root.replaceChildren();
const pages = Math.max(1, Math.ceil(layout.tiles.length / 6));
const { placement, pages } = packTiles(layout.tiles);
for (let page = 0; page < pages; page++) {
const frame = node("section", undefined, "screen-preview");
frame.append(node("small", `Pagina ${page + 1} · ${$("#title").value || "Thuis"}`, "preview-heading"));
const grid = node("div", undefined, "preview-grid");
for (let slot = page * 6; slot < Math.min(page * 6 + 6, tileLimit()); slot++) {
const tile = layout.tiles[slot];
let filled = 0;
layout.tiles.forEach((tile, index) => {
const place = placement[index];
if (place.page !== page) return;
// Keep the grid honest: an empty right column before a wide tile is a real gap on the screen.
for (; filled < place.slot; filled++) grid.append(node("span", "", "preview-gap"));
const card = node("button", undefined, "preview-tile");
if (tile) {
const name = tile.name || inventory.entities.find(e => e.id === tile.entity)?.name || tile.entity;
const background=inventory.backgrounds?.[tile.options?.background]?.color;
if(background)card.style.backgroundColor=background;
card.append(domainBadge(tile.entity), node("strong", name));
card.draggable = true;
card.ondragstart = (e) => { dragIndex = slot; e.dataTransfer.setData("text/plain", String(slot)); };
card.ondragend = () => { dragIndex = -1; };
card.ondragover = (e) => { if (dragIndex >= 0) e.preventDefault(); };
card.ondrop = (e) => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, slot); };
card.classList.toggle("chosen", selectedTile === tile.entity);
card.setAttribute("aria-label", `Tegel ${slot + 1}: ${name}, instellen`);
card.onclick = () => {
selectedTile = tile.entity;
renderTiles();
document.querySelectorAll("#tiles > .tile")[slot]?.scrollIntoView({behavior: "smooth", block: "center"});
};
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
if (tile.options?.display === "watch") card.append(node("small", "Grote waarde"));
} else {
card.classList.add("vacant");
const name = tile.name || entityName(tile.entity);
const background=inventory.backgrounds?.[tile.options?.background]?.color;
if(background)card.style.backgroundColor=background;
if (place.wide) card.classList.add("wide");
card.append(domainBadge(tile.entity), node("strong", name));
card.draggable = true;
card.ondragstart = (e) => { dragIndex = index; e.dataTransfer.setData("text/plain", String(index)); };
card.ondragend = () => { dragIndex = -1; };
card.ondragover = (e) => { if (dragIndex >= 0) e.preventDefault(); };
card.ondrop = (e) => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, index); };
card.classList.toggle("chosen", selectedTile === tile.entity);
card.setAttribute("aria-label", `Tegel ${index + 1}: ${name}, instellen`);
card.onclick = () => {
selectedTile = tile.entity;
renderTiles();
document.querySelectorAll("#tiles > .tile")[index]?.scrollIntoView({behavior: "smooth", block: "center"});
};
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
const display = tile.options?.display;
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
grid.append(card);
filled += place.wide ? 2 : 1;
});
if (layout.tiles.length < tileLimit())
for (; filled < 6; filled++) {
const card = node("button", undefined, "preview-tile vacant");
card.append(node("span", "+"), node("small", "Tegel toevoegen"));
card.onclick = () => { $("#search").focus(); $("#search").scrollIntoView({behavior:"smooth", block:"center"}); };
grid.append(card);
}
grid.append(card);
}
frame.append(grid);
root.append(frame);
}
@@ -282,8 +317,9 @@ function renderTiles() {
$("#tiles").replaceChildren();
$("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware voor 20":""}`;
$("#no-tiles").hidden = layout.tiles.length > 0;
const { placement } = packTiles(layout.tiles);
layout.tiles.forEach((tile, i) => {
if (i === 6) $("#tiles").append(node("li", "Pagina 2", "page-break"));
if (i > 0 && placement[i].page !== placement[i - 1].page) $("#tiles").append(node("li", `Pagina ${placement[i].page + 1}`, "page-break"));
const li = node("li", undefined, "tile");
li.classList.toggle("selected-tile", selectedTile === tile.entity);
li.draggable = true;
@@ -310,8 +346,7 @@ function renderTiles() {
const content = node("div"),
input = node("input");
input.value = tile.name;
input.placeholder =
inventory.entities.find((e) => e.id === tile.entity)?.name || tile.entity;
input.placeholder = entityName(tile.entity);
input.maxLength = 60;
input.setAttribute("aria-label", `Naam voor tegel ${i + 1}`);
input.oninput = () => {
@@ -383,6 +418,11 @@ function renderTiles() {
options.append(palette);
const controls = {};
const domain = tile.entity.split(".")[0];
const displays = domain === "screen"
? [["digital", "Digitale klok"], ["analog", "Analoge klok"]]
: [["standard", "Naam en status"], ["watch", "Grote waarde"]];
if (domain === "weather") displays.push(["forecast", "Weersvoorspelling (dubbelbreed)"]);
if (domain === "sensor") displays.push(["graph", "Grafiek van de geschiedenis"]);
const fields = [
[
"tap",
@@ -393,15 +433,10 @@ function renderTiles() {
["none", "Alleen bekijken"],
],
],
[
"display",
"Weergave",
[
["standard", "Naam en status"],
["watch", "Grote waarde"],
],
],
["display", "Weergave", displays],
["size", "Breedte (firmware 0.2.14+)", [["single", "Normaal"], ["wide", "Dubbelbreed"]]],
];
if (domain === "screen") fields.shift();
if (
["light", "switch", "input_boolean", "fan", "media_player"].includes(
domain,
@@ -449,10 +484,12 @@ function renderTiles() {
(key === "tap"
? "auto"
: key === "display"
? "standard"
? (domain === "screen" ? "digital" : "standard")
: key === "history_hours"
? 24
: "none");
: key === "size"
? "single"
: "none");
controls[key] = input;
input.onchange = () => {
tile.options = {
@@ -461,6 +498,8 @@ function renderTiles() {
};
if (key === "display" && input.value === "watch")
tile.options.inline = "none";
if (key === "display" && input.value === "forecast")
tile.options.size = "wide";
if (key === "inline" && input.value === "slider")
tile.options.display = "standard";
for (const [field, control] of Object.entries(controls)) {
@@ -473,9 +512,11 @@ function renderTiles() {
label.append(input);
options.append(label);
}
const inspectTile = node("button", "Inspecteer deze tegel", "quiet");
inspectTile.onclick = () => inspect(tile.entity);
options.append(inspectTile);
if (domain !== "screen") {
const inspectTile = node("button", "Inspecteer deze tegel", "quiet");
inspectTile.onclick = () => inspect(tile.entity);
options.append(inspectTile);
}
content.append(options);
li.append(domainBadge(tile.entity), content, actions);
$("#tiles").append(li);
@@ -485,10 +526,10 @@ function renderResults() {
if (!layout) return;
const query = $("#search").value.toLocaleLowerCase();
const chosen = new Set(layout.tiles.map((t) => t.entity));
const matches = inventory.entities.filter(
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
(e) =>
(!filter || e.id.startsWith(filter + ".") ||
({switch:"input_boolean", number:"input_number", select:"input_select"}[filter] === e.id.split(".")[0])) &&
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun"}[filter] === e.id.split(".")[0])) &&
`${e.name} ${e.id} ${e.device} ${e.area}`
.toLocaleLowerCase()
.includes(query),
@@ -592,6 +633,9 @@ for (const [value, label] of [
["weather", "Weer"],
["number", "Waarden"],
["select", "Keuzelijsten"],
["person", "Personen"],
["timer", "Kookwekkers"],
["screen", "Klok"],
]) {
const b = node("button", label, value === "" ? "active" : "");
if (value) b.prepend(domainBadge(value + "."));
@@ -826,7 +870,7 @@ async function inspect(entity) {
card.append(
node(
"small",
`Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${options.display === "watch" ? "grote waarde" : "standaard"} · Achtergrond: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Standaard"}`,
`Kleine slider: ${options.inline === "slider" ? "ja" : "nee"} · Weergave: ${displayNames[options.display || "standard"] || options.display} · Breedte: ${options.size === "wide" ? "dubbel" : "normaal"} · Achtergrond: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Standaard"}`,
),
);
$("#inspection-summary").append(card);
+3 -2
View File
@@ -74,8 +74,9 @@
<span id="count">0 / 10</span>
</div>
<p class="hint">
Klik op een tegel om bediening en weergave te kiezen. Sleep om
te ordenen. Zes tegels per pagina.
Klik op een tegel om bediening, weergave en breedte te kiezen.
Sleep om te ordenen. Zes vakjes per pagina; een dubbelbrede
tegel telt voor twee en begint altijd links.
</p>
<div id="layout-preview" aria-label="Schermindeling"></div>
<ol id="tiles"></ol>
+2
View File
@@ -711,6 +711,8 @@ pre {
.preview-tile strong { flex: 1; min-width: 0; font-size: 13px; overflow-wrap: anywhere; }
.preview-tile .domain-icon { width: 30px; height: 30px; flex-basis: 30px; font-size: 20px; }
.preview-tile.chosen { outline: 2px solid var(--accent); outline-offset: 1px; }
.preview-tile.wide { grid-column: span 2; }
.preview-gap { min-height: 90px; border: 1px dashed #d0d4d9; border-radius: 15px; }
.preview-tile.vacant { background: #f4f4f4; border-style: dashed; color: #727982; justify-content: center; }
.preview-slider { height: 13px; flex-basis: 100%; border-radius: 10px; background: linear-gradient(to right, #ffbf38 60%, #fff1d3 60%); }
.preview-tile small { flex-basis: 100%; }