Files
homeassistant_espscreen/screen_manager/app/static/app.js
T
Max Gramser 3e959b3df9 Put ESP Screens in English and keep layouts when entity IDs change (0.2.40)
Everything a user sees or reads is now English: both boards' labels,
status replies and logs, the ESP Screens editor and Alerts cheatsheet,
and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md
and TROUBLESHOOTING.md replace the Dutch file names). Labels follow
Home Assistant's own English names (Heat/Cool modes, Heating/Cooling
actions, Away, Returning to dock, weather conditions), dates use English
abbreviations and the top bar writes numbers the English way. Docs use
HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools).
Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed.

Firmware 0.2.34 names its entities in English. Home Assistant registers
a renamed ESPHome entity under a new entity ID, so the inbox
text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and
the layout stored under the old ID would be lost. The manager now
recognises the screen by its device, during a run or after a restart,
and moves its layout and update history along; the Update button and
the nightly round follow the screen through the rename. Screens that
still run 0.2.33 or older keep working: both languages are matched.

Main is the only release branch from now on.

Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards:
see docs/TEST_RESULTS_0240.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 02:08:43 +02:00

2392 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const $ = (s) => document.querySelector(s);
let inventory = { screens: [], entities: [] },
selected = null,
layout = null,
dirty = false,
filter = "",
busy = false,
selectedTile = null;
const node = (tag, text, cls) => {
const n = document.createElement(tag);
if (text !== undefined) n.textContent = text;
if (cls) n.className = cls;
return n;
};
function toast(message, action) {
const t = $("#toast");
t.replaceChildren(node("span", message));
if (action) {
const b = node("button", action.label);
b.type = "button";
b.onclick = () => { action.run(); t.hidden = true; };
t.append(b);
}
t.hidden = false;
clearTimeout(toast.timer);
toast.timer = setTimeout(() => (t.hidden = true), action ? 8000 : 5000);
}
async function api(path, options = {}) {
const response = await fetch(`api/${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"X-Screen-CSRF": inventory.csrf || "",
...options.headers,
},
});
if (!response.ok) {
let message = "That didn't work. Refresh the page and try again.";
try {
message = (await response.json()).error || message;
} catch {}
throw new Error(message);
}
return response;
}
function markDirty() {
dirty = true;
$("#dirty").textContent = "Unsaved changes";
$("#save-detail").textContent = "Click save to update your screen.";
}
function select(id) {
if (
id !== selected &&
dirty &&
!confirm(
"You have unsaved changes. Open a different screen anyway?",
)
)
return;
selected = id;
selectedTile = null;
if ($("#tile-sheet").open) $("#tile-sheet").close();
const screen = inventory.screens.find((s) => s.id === id);
if (!screen) return;
layout = structuredClone(screen.layout);
normalize();
insertAt = -1;
dirty = false;
$("#title").value = layout.title;
$("#screen-name").textContent = screen.name;
$("#editor").hidden = false;
$("#empty").hidden = true;
$("#dirty").textContent = "All saved";
$("#save-detail").textContent = "Changes apply without reflashing.";
renderScreens();
renderTopbar();
renderTiles();
renderResults();
renderSettings();
loadTopbarPreview(0);
}
const settingDefinitions = [
["standby_enabled", "Auto standby", "check", true],
["standby_seconds", "Standby after", "minutes", 600, 1, 1440],
["brightness", "Normal brightness", "range", 100, 5, 100],
["standby_brightness", "Standby brightness", "range", 20, 0, 100],
["night_enabled", "Use night mode", "check", true],
["night_brightness", "Night brightness", "range", 10, 0, 100],
["night_start", "Night starts", "time", 1320],
["night_end", "Night ends", "time", 420],
// 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-hour clock (off = 12 hour)", "check", true],
["home_on_standby", "Return to page 1 after standby", "check", false],
["rotation", "Rotate screen (clockwise)", "rotation", 0],
["swipe_pages", "Swipe between pages (Guition: from the edge, firmware 0.2.24+; CYD: quick swipe, 0.2.7+)", "check", false],
];
function renderSettings() {
const values = {
...Object.fromEntries(
settingDefinitions.map(([key, , , value]) => [key, value]),
),
...layout.settings,
};
const container = $("#settings-fields");
container.replaceChildren();
for (const [key, title, kind, , min, max] of settingDefinitions) {
if(key === "rotation" && inventory.screens.find(s => s.id === selected)?.board !== "guition") continue;
const label = node(
"label",
undefined,
`setting ${kind === "check" ? "setting-check" : ""}`,
);
const caption = node("span", title),
input = node(kind === "rotation" ? "select" : "input"),
output = node("output");
input.id = `setting-${key}`;
if(kind !== "rotation") input.type =
kind === "check" ? "checkbox" : kind === "minutes" ? "number" : kind;
input.setAttribute("aria-label", title);
input.required = kind === "minutes" || kind === "time";
if(kind === "rotation") {
for(const angle of [0,90,180,270]) { const option=node("option", `${angle}°`); option.value=angle; input.append(option); }
input.value=values[key];
}
else if (kind === "check") input.checked = values[key];
else if (kind === "time")
input.value = `${String(Math.floor(values[key] / 60)).padStart(2, "0")}:${String(values[key] % 60).padStart(2, "0")}`;
else {
input.min = min;
input.max = max;
input.step = 1;
input.value = kind === "minutes" ? values[key] / 60 : values[key];
}
const updateOutput = () =>
(output.textContent =
kind === "range"
? `${input.value}%`
: kind === "minutes"
? "minutes"
: "");
updateOutput();
input.oninput = () => {
if (!input.checkValidity() || input.value === "") return;
let value = kind === "check" ? input.checked : Number(input.value);
if (kind === "time") {
const [h, m] = input.value.split(":").map(Number);
value = h * 60 + m;
}
if (kind === "minutes") value *= 60;
layout.settings = { ...values, ...layout.settings, [key]: value };
// A lower normal brightness also lowers any higher standby levels.
if (key === "brightness")
for (const dim of ["standby_brightness", "night_brightness"]) {
layout.settings[dim] = Math.min(layout.settings[dim], value);
const other = $(`#setting-${dim}`);
other.value = layout.settings[dim];
other.parentElement.querySelector("output").textContent =
`${other.value}%`;
}
updateOutput();
markDirty();
};
label.append(caption, input, output);
container.append(label);
}
renderSettingsSupport();
}
function renderSettingsSupport() {
const version =
inventory.screens.find((s) => s.id === selected)?.firmware || "";
const parts = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/.exec(version);
const supported =
parts &&
(Number(parts[1]) > 0 ||
Number(parts[2]) > 1 ||
(Number(parts[2]) === 1 && Number(parts[3]) >= 2));
$("#settings-support").textContent = supported
? "Saving sends your settings straight to this screen. They're also kept after a restart."
: "Install firmware 0.1.2 or newer once via ESPHome. You can save the settings now; older firmware doesn't use them yet.";
}
const updating = new Set();
const PHASES = {
install: "Building and installing…",
verify: "Waiting for the screen to come back…",
settle: "Checking that it stays stable…",
};
async function startUpdate(screen, host) {
updating.add(screen.id);
renderScreens();
try {
await api(`screens/${encodeURIComponent(screen.id)}/update`, {
method: "POST",
body: JSON.stringify(host ? { host } : {}),
});
await refresh();
} catch (e) {
updating.delete(screen.id);
renderScreens();
toast(e.message);
}
}
function renderUpdate(screen) {
const u = screen.update || {};
const row = node("div", undefined, "screen-update");
const running = u.state === "running" || updating.has(screen.id);
if (running) {
row.append(
node("span", undefined, "spin"),
node("small", PHASES[u.phase] || "Starting update…"),
);
} else if (u.state === "queued") {
row.append(node("small", "Queued for the update"));
} else if (u.available && screen.online) {
row.append(node("span", `Update ${u.target}`, "badge update"));
const button = node("button", "Update", "mini");
button.type = "button";
if (u.host && u.profile) {
button.onclick = (e) => {
e.stopPropagation();
startUpdate(screen);
};
} else if (!u.profile) {
button.disabled = true;
button.title = "No ESPHome profile found with this device name.";
} else {
button.onclick = (e) => {
e.stopPropagation();
const form = node("form", undefined, "screen-host");
const input = node("input");
input.placeholder = "IP address, e.g. 192.168.1.50";
input.required = true;
input.pattern = "[A-Za-z0-9][A-Za-z0-9.\\-]*";
const go = node("button", "Start", "mini");
const cancel = node("button", "✕", "quiet");
cancel.type = "button";
cancel.setAttribute("aria-label", "Cancel");
cancel.onclick = () => {
form.remove();
renderScreens();
};
form.append(input, go, cancel);
form.onsubmit = (ev) => {
ev.preventDefault();
form.remove();
startUpdate(screen, input.value.trim());
};
row.replaceChildren(
node("small", "The IP address, once; new firmware reports it itself."),
form,
);
input.focus();
};
}
row.append(button);
} else if (u.result && Date.now() / 1000 - u.result.time < 86400) {
row.append(
node(
"small",
u.result.message,
u.result.state === "failed" ? "failed" : "",
),
);
} else return null;
return row;
}
function openIntegrations() {
// Pairing happens in Home Assistant itself. This page lives in HA's ingress iframe,
// so send the top window to Devices & services (same origin); elsewhere open a tab.
const path = "/config/integrations/dashboard";
try {
window.top.location.assign(path);
} catch {
window.open(path, "_blank");
}
}
async function copyText(text, element, what = "API key") {
try {
if (!navigator.clipboard || !window.isSecureContext) throw new Error();
await navigator.clipboard.writeText(text);
toast(`${what} copied.`);
} catch {
if (element) {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
toast(document.execCommand("copy") ? `${what} copied.` : `${what} is selected. Copy with Ctrl+C or Command+C.`);
}
}
// Profiles that Home Assistant does not list yet: the freshly flashed screen is not lost, it
// still has to be added under Devices & services, outside this page.
function renderPending() {
const box = $("#pending");
box.replaceChildren();
for (const p of inventory.pending || []) {
const card = node("div", undefined, "pending");
card.append(
node("strong", p.friendly),
node("small", p.installed
? "Installed, but not yet in Home Assistant. That happens outside ESP Screens: add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure."
: `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → ${p.file}.`),
);
const actions = node("div", undefined, "pending-actions");
const go = node("button", "Open Devices & services", "mini");
go.type = "button";
go.onclick = openIntegrations;
actions.append(go);
if (p.api_key) {
const copy = node("button", "Copy API key", "mini quiet");
copy.type = "button";
copy.onclick = () => copyText(p.api_key);
actions.append(copy);
}
card.append(actions);
box.append(card);
}
}
function renderScreens() {
renderPending();
// Keep an open address form alive across the periodic refresh.
if ($("#screens .screen-host")) {
renderUpdates();
return;
}
$("#screens").replaceChildren();
for (const screen of inventory.screens) {
if (screen.update?.state === "running") updating.delete(screen.id);
const item = node(
"div",
undefined,
`screen ${screen.id === selected ? "selected" : ""}`,
);
const b = node("button", undefined, "screen-main");
b.type = "button";
const meta = node("small");
meta.append(
node("span", screen.online ? "●" : "○", `dot ${screen.online ? "online" : ""}`),
` ${screen.online ? "Online" : "Offline"}${screen.area ? " · " + screen.area : ""} · firmware ${screen.firmware || "unknown"}`,
);
b.append(node("strong", screen.name), meta);
b.onclick = () => select(screen.id);
item.append(b);
const update = renderUpdate(screen);
if (update) item.append(update);
$("#screens").append(item);
}
const screen = inventory.screens.find((s) => s.id === selected);
if (screen) {
$("#delivery").textContent = screen.online
? `${screen.delivery} · ${screen.status}`
: "Offline · changes are saved";
$("#delivery").classList.toggle("online", screen.online);
}
renderUpdates();
}
function renderUpdates() {
const u = inventory.updates;
$("#updates").hidden = !u || !inventory.screens.length;
if (!u) return;
const outdated = inventory.screens.filter((s) => s.update?.available).length;
$("#updates-hint").textContent = u.busy
? `Updating to firmware ${u.target}…`
: outdated
? `Firmware ${u.target} is available for ${outdated} screen${outdated === 1 ? "" : "s"}.`
: `All screens have firmware ${u.target}.`;
$("#update-all").hidden = !u.pending || !!u.busy || u.pending < 2;
$("#update-all").textContent = `Update all ${u.pending} screens`;
if (document.activeElement !== $("#auto-update"))
$("#auto-update").checked = u.auto;
}
$("#update-all").onclick = async () => {
try {
await api("updates/run", { method: "POST" });
await refresh();
} catch (e) {
toast(e.message);
}
};
$("#auto-update").onchange = async () => {
try {
await api("updates", {
method: "PUT",
body: JSON.stringify({ auto: $("#auto-update").checked }),
});
toast(
$("#auto-update").checked
? "Screens will now update automatically at night."
: "Automatic updates are off.",
);
} catch (e) {
$("#auto-update").checked = !$("#auto-update").checked;
toast(e.message);
}
};
const domains = {
light: ["Light", "☀", "#ad7600", "#fff3d3"],
climate: ["Climate", "❄", "#c86620", "#ffebdc"],
vacuum: ["Vacuum", "◉", "#008577", "#def3ed"],
fan: ["Fan", "✣", "#008aab", "#def5fa"],
cover: ["Cover", "▤", "#8053af", "#eee5f8"],
media_player: ["Media", "▶", "#007cad", "#def2fc"],
sensor: ["Sensor", "⌁", "#3476b1", "#e5effa"],
binary_sensor: ["Status", "◈", "#ad7600", "#fff3d3"],
switch: ["Switch", "⏻", "#ad7600", "#fff3d3"],
input_boolean: ["Switch", "⏻", "#ad7600", "#fff3d3"],
scene: ["Scene", "✦", "#8053af", "#eee5f8"],
script: ["Script", "▷", "#8053af", "#eee5f8"],
weather: ["Weather", "☁", "#007cad", "#def2fc"],
number: ["Value", "±", "#008577", "#def3ed"],
input_number: ["Value", "±", "#008577", "#def3ed"],
select: ["Select", "≡", "#5862af", "#eaecfa"],
input_select: ["Select", "≡", "#5862af", "#eaecfa"],
button: ["Action", "↗", "#5862af", "#eaecfa"],
screen: ["Clock", "◷", "#25282c", "#e9ecf1"],
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
timer: ["Timer", "⏱", "#008577", "#def3ed"],
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
};
const displayNames = { standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph", digital: "digital clock", analog: "analog clock", sunpath: "sun path" };
// Same rule as the add-on: only a wide card in the standard layout shows direct controls;
// without a choice the domain's first control set applies.
function effectiveControls(tile) {
const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {};
if (!catalogue || o.size !== "wide" || (o.display || "standard") !== "standard" || o.inline === "slider") return null;
const choice = o.controls ?? catalogue.default;
return choice === "none" ? null : choice;
}
function controlsLabel(tile) {
const key = effectiveControls(tile);
if (!key) return "none";
return inventory.controls?.[tile.entity.split(".")[0]]?.choices.find((c) => c.key === key)?.label.toLocaleLowerCase() || key;
}
// Miniature of the control set on the mockup card: the same shapes the screen draws.
function controlsPreview(tile) {
const key = effectiveControls(tile), domain = tile.entity.split(".")[0], cp = inventory.icons?.controls || {};
if (!key) return null;
const box = node("span", undefined, "preview-controls");
const buttons = (...names) => { for (const name of names) box.append(node("span", cp[name] ? glyph(cp[name]) : "", "preview-key mdi")); };
const stepper = (text) => { const pill = node("span", undefined, "preview-stepper"); pill.append(node("span", cp.minus ? glyph(cp.minus) : "−", "mdi"), node("b", text), node("span", cp.plus ? glyph(cp.plus) : "+", "mdi")); box.append(pill); };
const slider = () => box.append(node("span", "", "preview-range"));
if (key === "toggle") box.append(node("span", "", "preview-toggle"));
else if (key === "setpoint") stepper("20°");
else if (key === "stepper") domain.endsWith("select") ? buttons("chevron-left", "chevron-right") : stepper("50");
else if (key === "mode") buttons("power", "fire", "snowflake");
else if (key === "volume") { slider(); buttons("volume-high"); }
else if (key === "playback") buttons("skip-previous", "play", "skip-next");
else if (key === "buttons" && domain === "cover") buttons("arrow-expand-horizontal", "stop", "arrow-collapse-horizontal");
else if (key === "buttons" && domain === "vacuum") buttons("play", "stop", "home-map-marker");
else if (key === "buttons" && domain === "timer") buttons("play", "close");
else if (key === "run") box.append(node("b", { scene: "Activate", script: "Run" }[domain] || "Press", "preview-run"));
else slider();
return box;
}
// New tiles start with the card that shows the entity best.
function defaultOptions(id) {
const domain = id.split(".")[0];
if (domain === "sun") return { options: { display: "sunpath", size: "wide" } };
if (domain === "weather") return { options: { display: "forecast", size: "wide" } };
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
return {};
}
// ---- Grid positions ----
// Two columns, three rows per page, at most eight pages. A tile's `slot` is its absolute
// cell (page * 6 + row * 2 + column); a wide tile starts in the left column and also covers
// the cell to its right. Empty cells are allowed and stay exactly where they are.
const SLOTS_PER_PAGE = 6, MAX_PAGES = 8, MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE;
const isWide = (tile) => tile.options?.size === "wide";
const cellsOf = (slot, wide) => (wide ? [slot, slot + 1] : [slot]);
const rowStart = (slot) => slot - (slot % 2);
const pageOf = (slot) => Math.floor(slot / SLOTS_PER_PAGE);
const liveEntries = () => layout.tiles.map((tile) => ({ tile, slot: tile.slot }));
// In-order packing: the rule before positions existed, and what firmware below 0.2.26 still draws.
function packSlots(tiles) {
let position = 0;
return tiles.map((tile) => {
const wide = isWide(tile);
if (wide && position % 2 === 1) position++;
const slot = position;
position += wide ? 2 : 1;
return slot;
});
}
function hasGaps(tiles) {
const packed = packSlots(tiles);
return tiles.some((tile, i) => tile.slot !== packed[i]);
}
// Every tile gets a position (older layouts pack in order), the list stays in reading
// order and the open settings sheet keeps following its tile.
function normalize() {
if (layout.tiles.some((t) => !Number.isInteger(t.slot))) { const packed = packSlots(layout.tiles); layout.tiles.forEach((t, i) => (t.slot = packed[i])); }
layout.tiles.sort((a, b) => a.slot - b.slot);
if (sheetIndex >= 0) sheetIndex = layout.tiles.findIndex((t) => t.entity === selectedTile);
}
function occupied(entries) {
const taken = new Set();
for (const { tile, slot } of entries) for (const cell of cellsOf(slot, isWide(tile))) taken.add(cell);
return taken;
}
const fits = (taken, slot, wide) => Number.isInteger(slot) && slot >= 0 && slot + (wide ? 1 : 0) < MAX_SLOTS && !(wide && slot % 2) && cellsOf(slot, wide).every((c) => !taken.has(c));
function firstFree(taken, wide) {
for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide)) return slot;
return -1;
}
// The free position closest to `origin`; on a tie the later one, so a nudged tile moves down, not up.
function nearestFree(taken, wide, origin) {
let best = -1;
for (let slot = 0; slot < MAX_SLOTS; slot++) if (fits(taken, slot, wide) && (best < 0 || Math.abs(slot - origin) <= Math.abs(best - origin))) best = slot;
return best;
}
// The arrangement after putting `moving` (a tile on the grid, or a new one) at `target`:
// it lands exactly there; tiles in its way take the cells it left (a swap) or else the
// nearest free cell; everything else stays put. Null when the target is off the grid.
function arrange(tiles, moving, target) {
const wide = isWide(moving);
if (wide) target = rowStart(target);
if (!fits(new Set(), target, wide)) return null;
const footprint = cellsOf(target, wide);
const vacated = tiles.includes(moving) ? cellsOf(moving.slot, wide) : [];
const result = [{ tile: moving, slot: target }], displaced = [];
for (const tile of tiles) {
if (tile === moving) continue;
if (cellsOf(tile.slot, isWide(tile)).some((c) => footprint.includes(c))) displaced.push(tile);
else result.push({ tile, slot: tile.slot });
}
for (const tile of displaced) {
const w = isWide(tile), taken = occupied(result);
let slot = vacated.map((c) => (w ? rowStart(c) : c)).find((c) => fits(taken, c, w));
if (slot === undefined) slot = nearestFree(taken, w, tile.slot);
if (slot < 0) return null;
result.push({ tile, slot });
}
return result.sort((a, b) => a.slot - b.slot);
}
// Apply an arrangement; a new tile joins the layout. True when anything changed.
function commit(result) {
const before = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join();
for (const { tile, slot } of result) { tile.slot = slot; if (!layout.tiles.includes(tile)) layout.tiles.push(tile); }
normalize();
const changed = layout.tiles.map((t) => `${t.entity}@${t.slot}`).join() !== before;
if (changed) { markDirty(); renderTiles(); renderResults(); }
return changed;
}
function placeTile(tile, target) {
const result = arrange(layout.tiles, tile, target);
return result ? commit(result) : false;
}
// Pages the tiles need, or more when the user keeps empty pages on purpose (`layout.pages`).
function pageCount(entries) {
const last = Math.max(0, ...entries.map(({ tile, slot }) => slot + (isWide(tile) ? 2 : 1)));
return Math.min(MAX_PAGES, Math.max(1, Math.ceil(last / SLOTS_PER_PAGE), layout.pages || 1));
}
function addPage() {
layout.pages = Math.min(MAX_PAGES, pageCount(liveEntries()) + 1);
markDirty();
renderTiles();
document.querySelector("#layout-preview .screen-preview:last-child")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// An empty page goes; the pages after it move up.
function removePage(page) {
for (const tile of layout.tiles) if (pageOf(tile.slot) > page) tile.slot -= SLOTS_PER_PAGE;
layout.pages = Math.max(1, pageCount(liveEntries()) - 1);
markDirty();
renderTiles();
}
function focusSlot(slot) {
document.querySelector(`#layout-preview [data-slot="${slot}"]`)?.focus();
}
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]] || ["Entity", "◇", "#637184", "#edf0f4"];
const badge = node("span", symbol, "domain-icon");
badge.title = title;
badge.setAttribute("aria-label", title);
badge.style.color = color;
badge.style.background = background;
return badge;
}
// The screen draws Material Design Icons; static/tile-icons.woff holds the same glyphs.
const glyph = (cp) => String.fromCodePoint(parseInt(cp, 16));
let iconIndex = { source: null, byName: {} };
function iconNamed(name) {
if (iconIndex.source !== inventory.icons)
iconIndex = { source: inventory.icons, byName: Object.fromEntries((inventory.icons?.groups || []).flatMap((g) => g.icons.map((i) => [i.name, i]))) };
return iconIndex.byName[name];
}
// What the firmware draws without a choice: Home Assistant's own icon, else the domain icon.
function automaticIcon(id) {
const icons = inventory.icons, entity = inventory.entities.find((e) => e.id === id), domain = id.split(".")[0];
if (entity?.icon) return entity.icon;
if (domain === "weather") return icons.weather[entity?.state] || icons.weather.partlycloudy;
if (domain === "sun") return icons.sun[entity?.state] || icons.sun.below_horizon;
return icons.defaults[domain] || icons.fallback;
}
function tileBadge(tile) {
const badge = domainBadge(tile.entity);
if (!inventory.icons) return badge;
badge.textContent = glyph(iconNamed(tile.options?.icon)?.cp || automaticIcon(tile.entity));
badge.classList.add("mdi");
return badge;
}
function tileLimit() {
const version=inventory.screens.find(s=>s.id===selected)?.firmware || "";
const match=/^(\d+)\.(\d+)\.(\d+)$/.exec(version);
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;
}
let insertAt = -1; // empty cell chosen as the place for the next tile from the picker
function renderPreview(preview) {
const root = $("#layout-preview");
root.replaceChildren();
// While dragging, the mockup already shows the arrangement after the drop.
const entries = preview?.result || liveEntries();
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);
// 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", `Page ${page + 1}`));
if (page >= pages) {
frame.classList.add("new-page");
heading.replaceChildren(node("small", `Page ${page + 1} · drag here for a new page`));
} else if (pages > 1 && !entries.some((e) => pageOf(e.slot) === page)) {
heading.append(node("small", "empty", "page-note"));
const drop = node("button", "Remove page", "mini");
drop.type = "button";
drop.title = "The pages after this one shift up one slot";
drop.onclick = () => removePage(page);
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;
if (covered.has(slot)) continue;
const entry = bySlot.get(slot);
grid.append(entry ? tileCard(entry.tile, slot, entry.tile === moving) : emptyCell(slot));
}
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) {
const index = layout.tiles.indexOf(tile);
const card = node("div", undefined, "preview-tile");
card.dataset.slot = slot;
const name = tile.name || entityName(tile.entity);
const background = inventory.backgrounds?.[tile.options?.background]?.color;
if (background) card.style.backgroundColor = background;
// "None": no card on the screen; the mockup keeps a dashed outline.
if (tile.options?.background === "none") card.classList.add("bare");
if (isWide(tile)) card.classList.add("wide");
card.append(tileBadge(tile), node("strong", name));
if (tile.options?.inline === "slider") card.append(node("span", "", "preview-slider"));
const controls = controlsPreview(tile);
if (controls) card.append(controls);
const display = tile.options?.display;
if (display && display !== "standard") card.append(node("small", displayNames[display] || display));
if (placeholder || index < 0) { card.classList.add("placeholder"); return card; }
card.tabIndex = 0;
card.setAttribute("role", "button");
card.setAttribute("aria-label", `${name}, slot ${(slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(slot) + 1}. Enter: configure, arrow keys: move`);
card.classList.toggle("chosen", selectedTile === tile.entity);
enableDrag(card, { kind: "tile", tile });
card.onclick = () => openTileSheet(index);
card.onkeydown = (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTileSheet(index); return; }
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 }[e.key];
if (!step) return;
e.preventDefault();
// A wide card owns its row: left and right mean the row above and below.
if (placeTile(tile, tile.slot + (isWide(tile) ? Math.sign(step) * 2 : step))) focusSlot(tile.slot);
};
const remove = node("button", "✕", "preview-remove");
remove.type = "button";
remove.title = "Remove tile";
remove.setAttribute("aria-label", `Remove ${name}`);
remove.onclick = (e) => { e.stopPropagation(); removeTile(index); };
card.append(remove);
return card;
}
// An empty cell: a drop target, and a click marks it as the place for the next tile.
function emptyCell(slot) {
const cell = node("button", undefined, "preview-cell");
cell.type = "button";
cell.dataset.slot = slot;
const marked = insertAt === slot;
cell.classList.toggle("insert-here", marked);
cell.append(node("span", "+"), node("small", marked ? "Next tile goes here" : "Empty"));
cell.title = "Empty slot. Click to add a tile here, or drag one over.";
cell.setAttribute("aria-label", `Empty slot ${(slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(slot) + 1}: add the next tile here`);
cell.onclick = () => {
insertAt = marked ? -1 : slot;
renderPreview();
if (insertAt >= 0) { $("#search").focus(); $("#search").scrollIntoView({ behavior: "smooth", block: "center" }); }
};
return cell;
}
function renderTiles() {
normalize();
// Pages follow the tiles; only a page the user added on purpose can stay empty.
layout.pages = pageCount(liveEntries());
renderPreview();
$("#add-page").disabled = layout.pages >= MAX_PAGES;
$("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware for 20":""}`;
$("#no-tiles").hidden = layout.tiles.length > 0;
// Firmware below 0.2.26 ignores positions and packs the tiles in order, without gaps.
const hint = $("#positions-hint");
hint.hidden = !hasGaps(layout.tiles) || supportsFirmware(0, 2, 26);
hint.textContent = `Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${inventory.screens.find((s) => s.id === selected)?.firmware || "unknown"}) shifts the tiles up to the first free slot until that update.`;
if (sheetIndex >= 0) renderTileSheet();
}
function removeTile(index) {
const [tile] = layout.tiles.splice(index, 1);
if (!tile) return;
if (sheetIndex >= 0) closeTileSheet();
markDirty();
renderTiles();
renderResults();
toast(`${tile.name || entityName(tile.entity)} removed`, {
label: "Undo",
run: () => placeTile(tile, tile.slot),
});
}
// Tile settings open in a sheet above the mockup; every change applies live,
// so the card behind it shows the result while you pick.
let sheetIndex = -1, iconPickerOpen = false;
function openTileSheet(index) {
if (index !== sheetIndex) iconPickerOpen = false;
sheetIndex = index;
selectedTile = layout.tiles[index].entity;
renderTileSheet();
renderPreview();
const sheet = $("#tile-sheet");
if (!sheet.open) sheet.showModal();
}
function closeTileSheet() {
sheetIndex = -1;
selectedTile = null;
const sheet = $("#tile-sheet");
if (sheet.open) sheet.close();
renderPreview();
}
$("#tile-sheet").addEventListener("close", () => { if (sheetIndex >= 0) { sheetIndex = -1; selectedTile = null; renderPreview(); } });
function field(title, control) {
const label = node("label", undefined, "sheet-field");
label.append(node("span", title), control);
return label;
}
function segmented(choices, value, onChange) {
const group = node("div", undefined, "segmented");
for (const [key, text] of choices) {
const b = node("button", text);
b.type = "button";
b.setAttribute("aria-pressed", String(String(key) === String(value)));
b.onclick = () => { for (const other of group.children) other.setAttribute("aria-pressed", String(other === b)); onChange(key); };
group.append(b);
}
return group;
}
// A choice applies live like the palette: only pressed states, the summary and the
// badges update, so search text and scroll position survive.
function iconField(tile, onChange) {
const fromHA = Boolean(inventory.entities.find((e) => e.id === tile.entity)?.icon);
return iconPicker({
selected: tile.options?.icon || "auto",
automatic: automaticIcon(tile.entity),
autoLabel: `Automatic (${fromHA ? "from Home Assistant" : "default"})`,
onPick: (name) => { tile.options = { ...tile.options, icon: name }; markDirty(); renderPreview(); onChange(); },
note: supportsFirmware(0, 2, 18) ? "" : "The screen shows a chosen icon from 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", "Icon"));
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 ? "Close" : "Change");
summary.append(current, text, action);
const describe = () => {
const chosen = iconNamed(picked);
current.textContent = picked === "none" ? "" : glyph(chosen?.cp || automatic);
text.textContent = picked === "none" ? "No icon" : chosen?.label || autoLabel;
};
describe();
const panel = node("div", undefined, "icon-picker");
panel.hidden = !iconPickerOpen;
summary.onclick = () => {
iconPickerOpen = panel.hidden;
panel.hidden = !iconPickerOpen;
summary.setAttribute("aria-expanded", String(iconPickerOpen));
action.textContent = iconPickerOpen ? "Close" : "Change";
};
const choice = (name, cp, label, cls = "") => {
const b = node("button", undefined, `icon-choice ${cls}`);
b.type = "button";
b.dataset.icon = name;
b.dataset.search = label.toLocaleLowerCase();
b.title = label;
b.setAttribute("aria-label", `Icon: ${label}`);
b.setAttribute("aria-pressed", String(picked === name));
b.append(node("span", cp ? glyph(cp) : "", "mdi"));
b.onclick = () => {
picked = name;
for (const other of panel.querySelectorAll(".icon-choice")) other.setAttribute("aria-pressed", String(other === b));
describe();
onPick(name);
};
return b;
};
const search = node("input");
search.type = "search";
search.placeholder = "Search, for example lamp, music, or door";
search.setAttribute("aria-label", "Search for an icon");
const auto = choice("auto", automatic, autoLabel, "icon-auto");
auto.append(node("span", autoLabel));
const none = allowNone ? choice("none", "", "No icon", "icon-auto icon-none") : null;
none?.append(node("span", "No icon, text only"));
const list = node("div", undefined, "icon-list"), empty = node("p", "No icon found.", "hint");
empty.hidden = true;
const sections = inventory.icons.groups.map((group) => {
const section = node("section"), grid = node("div", undefined, "icon-grid");
for (const icon of group.icons) {
const b = choice(icon.name, icon.cp, icon.label);
b.dataset.search += ` ${icon.name.replaceAll("-", " ")} ${group.label.toLocaleLowerCase()}`;
grid.append(b);
}
section.append(node("small", group.label), grid);
list.append(section);
return section;
});
search.oninput = () => {
const query = search.value.trim().toLocaleLowerCase();
let found = 0;
for (const section of sections) {
let visible = 0;
for (const b of section.querySelectorAll(".icon-choice")) {
b.hidden = !b.dataset.search.includes(query);
if (!b.hidden) visible++;
}
section.hidden = !visible;
found += visible;
}
empty.hidden = found > 0;
};
panel.append(search, auto, ...(none ? [none] : []), list, empty);
wrap.append(summary, panel);
if (note) wrap.append(node("small", note, "icon-hint"));
return wrap;
}
function renderTileSheet() {
const sheet = $("#tile-sheet"), tile = layout.tiles[sheetIndex];
if (!tile) return;
sheet.replaceChildren();
const domain = tile.entity.split(".")[0], name = entityName(tile.entity);
const head = node("div", undefined, "sheet-head"), titles = node("div");
titles.append(node("strong", name), node("small", tile.entity));
const close = node("button", "✕", "quiet sheet-close");
close.type = "button";
close.setAttribute("aria-label", "Close");
close.onclick = closeTileSheet;
let badge = tileBadge(tile);
head.append(badge, titles, close);
const body = node("div", undefined, "sheet-body");
const nameInput = node("input");
nameInput.value = tile.name;
nameInput.placeholder = name;
nameInput.maxLength = 60;
nameInput.oninput = () => { tile.name = nameInput.value; markDirty(); renderPreview(); };
body.append(field("Name on the screen", nameInput));
// The clock, forecast and sun path cards draw no tile icon.
if (inventory.icons && domain !== "screen" && !["forecast", "sunpath"].includes(tile.options?.display))
body.append(iconField(tile, () => { const next = tileBadge(tile); badge.replaceWith(next); badge = next; }));
const displays = domain === "screen"
? [["digital", "Digital clock"], ["analog", "Analog clock"]]
: [["standard", "Name and status"], ["watch", "Large value"]];
if (domain === "weather") displays.push(["forecast", "Weather forecast"]);
if (domain === "sensor") displays.push(["graph", "Graph"]);
if (domain === "sun") displays.push(["sunpath", "Sun path"]);
const current = (key, fallback) => tile.options?.[key] ?? fallback;
const set = (key, value) => {
const wasWide = isWide(tile);
tile.options = { ...tile.options, [key]: value };
// Direct controls need the standard layout without a mini slider, and vice versa.
if (key === "display" && value === "watch") { tile.options.inline = "none"; if (inventory.controls?.[domain]) tile.options.controls = "none"; }
if (key === "display" && ["forecast", "sunpath"].includes(value)) tile.options.size = "wide";
if (key === "inline" && value === "slider") { tile.options.display = "standard"; if (inventory.controls?.[domain]) tile.options.controls = "none"; }
if (key === "controls" && value !== "none") { tile.options.display = "standard"; tile.options.inline = "none"; }
markDirty();
// A card that becomes double-wide keeps its row when the cell beside it is free, else it
// takes the nearest free row (below first); every other tile stays where it is.
if (isWide(tile) && !wasWide) {
const taken = occupied(liveEntries().filter((e) => e.tile !== tile)), own = rowStart(tile.slot);
const slot = fits(taken, own, true) ? own : nearestFree(taken, true, own);
if (slot >= 0) tile.slot = slot;
}
renderTiles();
};
body.append(field("Display", segmented(displays, current("display", domain === "screen" ? "digital" : "standard"), (v) => set("display", v))));
body.append(field("Width", segmented([["single", "Normal"], ["wide", "Double-width"]], current("size", "single"), (v) => set("size", v))));
const catalogue = inventory.controls?.[domain];
if (catalogue && current("size", "single") === "wide") {
const wrap = field("Direct control on the tile", segmented(catalogue.choices.map((c) => [c.key, c.label]), current("controls", catalogue.default), (v) => set("controls", v)));
wrap.append(node("small", supportsFirmware(0, 2, 19)
? "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as configured below."
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.", "field-hint"));
body.append(wrap);
}
if (domain !== "screen") {
const taps = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]];
if (["light", "switch", "input_boolean", "fan", "media_player", "climate"].includes(domain)) taps.push(["toggle", "On / off"]);
body.append(field("On tap", segmented(taps, current("tap", "auto"), (v) => set("tap", v))));
}
if (["light", "fan", "cover", "number", "input_number", "media_player"].includes(domain))
body.append(field("Small slider on the tile", segmented([["none", "No"], ["slider", "Yes, control directly"]], current("inline", "none"), (v) => set("inline", v))));
if (domain === "sensor")
body.append(field("History", segmented([[1, "1 hour"], [6, "6 hours"], [24, "24 hours"]], current("history_hours", 24), (v) => set("history_hours", Number(v)))));
const palette = node("div", undefined, "sheet-field");
palette.append(node("span", "Pastel background"));
const swatches = node("div", undefined, "palette-swatches");
for (const [key, choice] of Object.entries(inventory.backgrounds || {})) {
const button = node("button", undefined, "palette-choice");
button.type = "button";
button.setAttribute("aria-label", `Background: ${choice.label}`);
button.setAttribute("aria-pressed", String((tile.options?.background || "auto") === key));
const sample = node("span", undefined, "palette-sample");
if (choice.color) sample.style.backgroundColor = choice.color;
else sample.classList.add(key === "none" ? "palette-none" : "palette-auto");
button.append(sample, node("span", choice.label));
button.onclick = () => {
tile.options = { ...tile.options, background: key };
for (const other of swatches.children) other.setAttribute("aria-pressed", String(other === button));
markDirty();
renderPreview();
};
swatches.append(button);
}
palette.append(swatches);
body.append(palette);
const foot = node("div", undefined, "sheet-foot");
const remove = node("button", "Remove", "quiet danger");
remove.type = "button";
remove.onclick = () => removeTile(sheetIndex);
foot.append(remove);
if (domain !== "screen") {
const inspectButton = node("button", "Inspect", "quiet");
inspectButton.type = "button";
inspectButton.onclick = () => { const entity = tile.entity; closeTileSheet(); inspect(entity); };
foot.append(inspectButton);
}
const done = node("button", "Done");
done.type = "button";
done.onclick = closeTileSheet;
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 ago" themselves.
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
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 `In ${Math.max(1, per(60))} min`;
if (span < 86400) return per(3600) === 1 ? "In 1 hour" : `In ${per(3600)} hours`;
if (span < 172800) return "Tomorrow";
return `In ${per(86400)} days`;
}
if (span < 60) return "Just now";
if (span < 3600) return `${per(60)} min ago`;
if (span < 86400) return per(3600) === 1 ? "1 hour ago" : `${per(3600)} hours ago`;
if (span < 172800) return "Yesterday";
if (span < 604800) return `${per(86400)} days ago`;
if (span < 2592000) return per(604800) === 1 ? "1 week ago" : `${per(604800)} weeks ago`;
if (span < 31536000) return per(2592000) === 1 ? "1 month ago" : `${per(2592000)} months ago`;
return per(31536000) === 1 ? "1 year ago" : `${per(31536000)} years ago`;
}
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"', "Away 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 || "Home") {
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", `Top bar: ${lay.nameText}`);
const wrap = node("div", undefined, "preview-bar-wrap");
wrap.title = "Edit top bar";
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 "how it looks on the screen").
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", "+ Add", "topbar-add");
add.type = "button";
add.disabled = items.length >= topbarMax();
add.title = add.disabled ? `Maximum ${topbarMax()} items` : "Add the time, date, analog clock, or an entity";
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 ? "Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name." : "Drag to reorder; tap an item to configure it."
: `Sensors, the date, and the analog clock appear from firmware ${needed}; until that update, this screen shows the name and, if the time is in the bar, the clock.`;
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 ? "Hidden: not active right now" : topbarOverflow.has(index) ? "Doesn't fit next to the name" : view.analog ? "Dial" : view.text;
texts.append(node("strong", topbarLabel(item)), node("small", detail));
const remove = node("button", "✕", "chip-remove");
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${topbarLabel(item)} from the top bar`);
remove.onclick = (e) => { e.stopPropagation(); removeTopbarItem(index); };
chip.append(icon, texts, remove);
chip.setAttribute("aria-label", `${topbarLabel(item)}, slot ${index + 1}. Enter: configure, arrows: move`);
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)} removed from the top bar`, {
label: "Undo",
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(`The top bar has room for ${topbarMax()} items.`);
if (items.some((other) => itemKey(other) === itemKey(item))) return toast("This is already in the top bar.");
// 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", "Close");
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("What to show", content);
contentField.append(node("small", "Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.", "field-hint"));
body.append(contentField);
body.append(iconPicker({
selected: item.icon,
automatic: topbarPreviews.get(itemKey(item))?.auto_icon || automaticIcon(item.entity),
autoLabel: "Automatic (like 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("Show", shows);
showField.append(node("small", "Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.", "field-hint"));
body.append(showField);
} else {
badge.textContent = glyph(iconNamed(BUILTIN_ICONS[item.type])?.cp || "");
sheet.append(sheetHead(badge, topbarLabel(item), "From the screen itself, also works without 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 hour"], ["12", "12 hour"]], (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("Format", format);
formatField.append(node("small", "Applies to every clock on this screen, including the clock tiles.", "field-hint"));
body.append(formatField);
}
}
const foot = node("div", undefined, "sheet-foot");
const remove = node("button", "Remove", "quiet danger");
remove.type = "button";
remove.onclick = () => removeTopbarItem(topbarSheetIndex);
const left = node("button", "← Move left", "quiet"), right = node("button", "Move right →", "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", "Done");
done.type = "button";
done.onclick = () => sheet.close();
foot.append(remove, left, right, done);
sheet.append(body, foot);
renderTopbarLive();
}
// "How it looks": 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 ? "Hidden right now: not active" : topbarOverflow.has(topbarSheetIndex) ? "Doesn't currently fit next to the name" : "How it looks on the screen";
live.replaceChildren(node("small", note), itemSample(item));
}
function renderTopbarAdd(sheet) {
const badge = node("span", "+", "domain-icon");
sheet.append(sheetHead(badge, "Add to the top bar", `${topbarItems().length} of ${topbarMax()} slots used`));
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: "Small dial with the time", 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)) ? "Already added" : samples[builtin.type], () => addTopbarItem(item), taken.has(itemKey(item))));
}
body.append(group("From the screen itself", 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 ? "Already added" : [s.name, s.area].filter(Boolean).join(" · "), () => addTopbarItem(s.item), exists));
}
body.append(group("Suggestions from Home Assistant", list));
}
const search = node("input");
search.type = "search";
search.placeholder = "Search by name, room, or entity, e.g. temperature or door";
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", "No entities found.", "hint"));
else if (matches.length > 40) results.append(node("p", `${matches.length} results. Keep typing to narrow it down.`, "hint"));
};
search.oninput = renderMatches;
search.setAttribute("aria-label", "Search for an entity for the top bar");
const searchField = group("An entity", search);
searchField.append(results);
body.append(searchField);
renderMatches();
const foot = node("div", undefined, "sheet-foot");
const cancel = node("button", "Cancel", "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;
const tile = { entity: id, name: "", ...defaultOptions(id) };
const slot = insertAt >= 0 ? insertAt : firstFree(occupied(liveEntries()), isWide(tile));
insertAt = -1;
selectedTile = id;
if (slot >= 0) placeTile(tile, slot);
}
// Pointer-based drag & drop, mouse and touch, from the picker into the mockup and between
// cells. Touch starts after a short hold so the page still scrolls. While dragging, the
// mockup already shows where everything ends up; the drop confirms exactly that, and a
// drop off the grid changes nothing. A finished drag never doubles as a click.
const drag = { active: false, source: null, element: null, ghost: null, timer: 0, start: null, offset: null, pointerId: null, suppressUntil: 0, last: null, scroller: 0, moving: null, target: null, preview: null };
function enableDrag(element, source) {
element.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || element.disabled || e.target.closest(".preview-remove")) return;
// No text selection while the mouse drags; touch keeps its default so the page can scroll.
if (e.pointerType !== "touch") e.preventDefault();
Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId, active: false });
// A fast flick may leave the card before its first move event: keep the pointer until the drag begins.
try { element.setPointerCapture(e.pointerId); } catch {}
clearTimeout(drag.timer);
if (e.pointerType === "touch") drag.timer = setTimeout(() => beginDrag(e), 260);
});
element.addEventListener("pointermove", (e) => {
if (drag.active || !drag.start || drag.element !== element) return;
const distance = Math.hypot(e.clientX - drag.start.x, e.clientY - drag.start.y);
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; }
if (distance >= 6) beginDrag(e);
});
const cancel = () => { if (drag.element === element && !drag.active) { clearTimeout(drag.timer); drag.start = null; } };
element.addEventListener("pointerup", cancel);
element.addEventListener("pointercancel", cancel);
}
function beginDrag(e) {
if (drag.active || !drag.start) return;
drag.active = true;
drag.moving = drag.source.kind === "tile" ? drag.source.tile : { entity: drag.source.id, name: "", ...defaultOptions(drag.source.id) };
drag.target = null;
drag.preview = null;
getSelection()?.removeAllRanges();
const rect = drag.element.getBoundingClientRect();
const ghost = drag.element.cloneNode(true);
ghost.classList.add("drag-ghost");
ghost.style.width = `${rect.width}px`;
drag.offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
document.body.append(ghost);
drag.ghost = ghost;
document.body.classList.add("dragging");
// The mockup re-renders while hovering, so the pointer is followed on the document, not the card.
document.addEventListener("pointermove", moveDrag);
document.addEventListener("pointerup", finishDrag);
document.addEventListener("pointercancel", finishDrag);
try { drag.element.releasePointerCapture(drag.pointerId); } catch {}
try { document.documentElement.setPointerCapture(drag.pointerId); } catch {}
document.addEventListener("touchmove", blockScroll, { passive: false });
// Near the viewport edges the page scrolls along, so the mockup can be reached on small screens.
drag.scroller = setInterval(() => {
if (!drag.last) return;
const step = drag.last.y < 70 ? -12 : drag.last.y > innerHeight - 70 ? 12 : 0;
if (step) { window.scrollBy(0, step); setTarget(slotAt(drag.last.x, drag.last.y)); }
}, 16);
setTarget(-1);
moveDrag(e);
}
function blockScroll(e) { if (drag.active) e.preventDefault(); }
function finishDrag(e) { if (e.pointerId === drag.pointerId) endDrag(e.type === "pointerup"); }
function moveDrag(e) {
if (!drag.ghost || e.pointerId !== drag.pointerId) return;
drag.last = { x: e.clientX, y: e.clientY };
drag.ghost.style.transform = `translate(${e.clientX - drag.offset.x}px, ${e.clientY - drag.offset.y}px)`;
setTarget(slotAt(e.clientX, e.clientY));
}
// The cell under the pointer: the nearest card or empty cell (the gaps between them count
// too); on a wide card the left or right half decides. -1 away from the mockup.
function slotAt(x, y) {
let best = null, nearest = Infinity;
for (const cell of document.querySelectorAll("#layout-preview [data-slot]")) {
const r = cell.getBoundingClientRect();
const distance = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
if (distance < nearest) { nearest = distance; best = { cell, r }; }
}
if (!best || nearest > 16) return -1;
let slot = Number(best.cell.dataset.slot);
if (best.cell.classList.contains("wide") && x > (best.r.left + best.r.right) / 2) slot += 1;
return slot;
}
function setTarget(slot) {
if (drag.target === slot) return;
drag.target = slot;
const result = slot >= 0 ? arrange(layout.tiles, drag.moving, slot) : null;
drag.preview = result;
// Off the grid: a tile from the grid shows where it came from; a new one shows nowhere yet.
renderPreview({ result: result || liveEntries(), moving: drag.moving });
}
function endDrag(drop) {
const { preview } = drag;
document.removeEventListener("pointermove", moveDrag);
document.removeEventListener("pointerup", finishDrag);
document.removeEventListener("pointercancel", finishDrag);
document.removeEventListener("touchmove", blockScroll);
try { document.documentElement.releasePointerCapture(drag.pointerId); } catch {}
drag.ghost?.remove();
Object.assign(drag, { ghost: null, active: false, suppressUntil: Date.now() + 400, last: null, start: null, moving: null, target: null, preview: null });
clearInterval(drag.scroller);
document.body.classList.remove("dragging");
if (!(drop && preview && commit(preview))) renderPreview();
}
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
function renderResults() {
if (!layout) return;
const query = $("#search").value.toLocaleLowerCase();
const chosen = new Set(layout.tiles.map((t) => t.entity));
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
(e) =>
(!filter || e.id.startsWith(filter + ".") ||
({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),
);
$("#results").replaceChildren();
for (const entity of matches.slice(0, 80)) {
const b = node("button", undefined, "result"),
description = node("span");
description.append(
node("strong", entity.name),
node("small", domains[entity.id.split(".")[0]]?.[0] || entity.id.split(".")[0], "domain-label"),
node("small", [entity.area, entity.device].filter(Boolean).join(" · ")),
node("small", entity.id),
);
b.append(
domainBadge(entity.id),
description,
node("span", chosen.has(entity.id) ? "✓" : "+", "plus"),
);
b.disabled = chosen.has(entity.id) || layout.tiles.length >= tileLimit();
b.onclick = () => addTile(entity.id);
if (!b.disabled) enableDrag(b, { kind: "entity", id: entity.id });
$("#results").append(b);
}
if (!matches.length)
$("#results").append(
node("p", "No entities found. Try a different name.", "hint"),
);
if (matches.length > 80)
$("#results").append(
node(
"p",
`${matches.length} results. Keep typing to narrow it down.`,
"hint",
),
);
}
async function refresh(full = true) {
try {
const data = await (await api(full ? "inventory" : "inventory?light=1")).json();
// A light poll carries only screens and update status; keep the catalogues we have.
inventory = full ? data : { ...inventory, ...data };
$("#connection").textContent = inventory.connected
? "● Home Assistant connected"
: "Reconnecting to Home Assistant…";
$("#connection").classList.toggle("online", inventory.connected);
renderScreens();
if (selected) renderSettingsSupport();
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
if ($("#alerts-dialog").open) renderAlertScreens();
} catch {
$("#connection").textContent =
"Management page unreachable · retrying…";
}
}
$("#save").onclick = async () => {
if (busy) return;
for (const input of document.querySelectorAll("#settings-fields input")) {
if (!input.reportValidity()) return;
}
busy = true;
$("#save").disabled = true;
try {
layout.title = $("#title").value;
await api(`screens/${encodeURIComponent(selected)}`, {
method: "PUT",
body: JSON.stringify(layout),
});
dirty = false;
$("#dirty").textContent = "Saved";
$("#save-detail").textContent =
"Sync follows automatically, even after reconnecting.";
toast("Saved. Your screen is being updated.");
await refresh();
} catch (e) {
toast(e.message);
} finally {
((busy = false), (selectedTile = null));
$("#save").disabled = false;
}
};
$("#title").oninput = () => { markDirty(); renderPreview(); };
$("#add-page").onclick = addPage;
$("#search").oninput = renderResults;
$("#refresh").onclick = refresh;
for (const [value, label] of [
["", "All"],
["light", "Lights"],
["climate", "Climate"],
["switch", "Switches"],
["binary_sensor", "Status"],
["button", "Actions"],
["script", "Scripts"],
["fan", "Fans"],
["cover", "Covers"],
["scene", "Scenes"],
["vacuum", "Vacuum"],
["sensor", "Sensors"],
["media_player", "Media"],
["weather", "Weather"],
["number", "Values"],
["select", "Selects"],
["person", "People"],
["timer", "Timers"],
["screen", "Clock"],
]) {
const b = node("button", label, value === "" ? "active" : "");
if (value) b.prepend(domainBadge(value + "."));
b.onclick = () => {
filter = value;
$("#filters")
.querySelectorAll("button")
.forEach((n) => n.classList.remove("active"));
b.classList.add("active");
renderResults();
};
$("#filters").append(b);
}
// ----- New screen: profile, Wi-Fi, and the first flash in one window -----
const installer = {
poll: null, view: "setup", file: null, friendly: "", board: "cyd", target: "",
apiKey: null, nodeEdited: false, ports: null, jobState: null,
};
// ESPHome's node-name rule: lowercase ASCII, digits and dashes, starting with a letter.
function slug(text) {
const clean = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 30).replace(/-+$/, "");
return clean || "screen";
}
function portLabel(port) {
const id = port.replace(/^\/dev\/serial\/by-id\/usb-/, "").replace(/-if\d+(-port\d+)?$/, "").replace(/_/g, " ");
return `USB · ${id === port ? port.replace(/^\/dev\//, "") : id}`;
}
function syncNode() {
const form = $("#install-form");
if (!installer.nodeEdited) form.elements.name.value = slug(form.elements.friendly_name.value);
$("#node-preview").textContent = form.elements.name.value || "…";
}
function renderTargets(ports) {
const select = $("#install-target");
const key = ports.join("\n");
if (installer.ports === key) return;
installer.ports = key;
const current = select.value;
const later = node("option", "Later · save profile only");
later.value = "";
select.replaceChildren(...ports.map((p) => { const o = node("option", portLabel(p)); o.value = p; return o; }), later);
select.value = ports.includes(current) ? current : ports[0] || "";
renderTargetHint();
}
function renderTargetHint() {
const select = $("#install-target");
const ports = select.options.length - 1;
$("#target-hint").textContent = !ports
? "No USB port found. Connect the screen with a data cable to the Home Assistant machine; the list refreshes on its own."
: !select.value
? "The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."
: ports > 1
? "More than one board connected: choose this screen's port."
: "Once over USB; after that, everything is wireless.";
$("#install-go").textContent = select.value ? "Install" : "Save profile";
}
function renderWifi(wifi) {
const fields = $("#wifi-fields");
const ask = wifi?.state === "new" || wifi?.state === "missing";
fields.hidden = !ask;
fields.disabled = !ask;
if (ask) {
const missing = wifi.missing || [];
$("#wifi-ssid-label").hidden = !missing.includes("wifi_ssid");
$("#wifi-password-label").hidden = !missing.includes("wifi_password");
$("#wifi-status").textContent = wifi.state === "new"
? "Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically."
: "Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines.";
}
return wifi?.state === "ready"
? "Wi-Fi comes from your ESPHome secrets.yaml."
: wifi?.state === "invalid"
? "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten."
: "";
}
async function installerRefresh() {
let data;
try {
data = await (await api("firmware")).json();
} catch (e) {
$("#install-note").textContent = e.message;
return;
}
const job = data.job;
const ours = job && installer.file && job.file === installer.file;
if (ours) installer.jobState = job.state;
if (installer.view === "setup") {
if (ours && job.state === "running") return showProgress(job, data.logs);
renderTargets(data.ports || []);
const wifiNote = renderWifi(data.wifi);
const busy = job?.state === "running";
const flashing = !!$("#install-target").value;
$("#install-note").textContent = busy
? `A build or installation is already running (${job.file}). Wait for it to finish.`
: !data.available && flashing
? "The ESPHome CLI is missing from this installation; only saving the profile is possible."
: wifiNote;
$("#install-go").disabled = data.wifi?.state === "invalid" || (flashing && (busy || !data.available));
} else if (installer.view === "progress" && ours) {
renderProgress(job, data.logs);
}
}
function showProgress(job, logs) {
installer.view = "progress";
installer.jobState = job.state;
$("#install-setup").hidden = true;
$("#install-progress").hidden = false;
$("#install-log-wrap").hidden = false;
renderProgress(job, logs);
}
function outcome(ok) {
$("#progress-spin").hidden = true;
$("#progress-mark").hidden = false;
$("#progress-mark").textContent = ok ? "✓" : "✕";
$("#progress-mark").className = `outcome ${ok ? "ok" : "bad"}`;
}
function renderProgress(job, logs) {
const running = job.state === "running";
const ok = job.state === "success";
if (running) {
$("#progress-spin").hidden = false;
$("#progress-mark").hidden = true;
} else outcome(ok);
$("#install-title").textContent = running ? "One moment…" : ok ? "Done." : "That didn't work.";
$("#progress-title").textContent = running
? job.stage === "upload" ? `Writing firmware to ${installer.friendly}…` : "Building firmware…"
: ok ? `Firmware is on ${installer.friendly}` : "Install failed";
$("#progress-detail").textContent = running
? job.stage === "upload"
? "Don't disconnect the USB cable yet."
: "A first build takes a few minutes on a Raspberry Pi. You can close this window: the installation keeps running and you'll find it again under New screen."
: ok
? `The screen boots up and connects to your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""} Pair it with Home Assistant now:`
: logs.filter((l) => /error/i.test(l)).pop() || logs.filter((l) => /failed/i.test(l)).pop() || "See the log below.";
const pre = $("#install-log");
const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8;
pre.textContent = logs.join("\n");
if (stick) pre.scrollTop = pre.scrollHeight;
if (!running && !ok) $("#install-log-wrap").open = true;
$("#install-result").hidden = !ok;
if (ok) renderSteps();
$("#install-retry").hidden = running || ok;
$("#install-close").textContent = ok ? "Done" : "Close";
$("#install-close").classList.toggle("quiet", !ok);
}
function showSaved() {
installer.view = "done";
$("#install-setup").hidden = true;
$("#install-progress").hidden = false;
outcome(true);
$("#install-title").textContent = "Profile saved.";
$("#progress-title").textContent = `${installer.file} is in the ESPHome folder`;
$("#progress-detail").textContent =
"You can install once the screen is connected to the Home Assistant machine: Firmware & USB → this profile → USB port → Build & install. Or open the profile in ESPHome Device Builder (same folder) and flash from your browser. Save the API key for pairing:";
$("#install-result").hidden = false;
renderSteps();
$("#install-log-wrap").hidden = true;
$("#install-retry").hidden = true;
$("#install-close").textContent = "Done";
$("#install-close").classList.remove("quiet");
}
function renderSteps() {
$("#api-key").textContent = installer.apiKey || "";
const steps = [
["Go to Home Assistant → Settings → Devices & services.", ` This happens outside ESP Screens. Home Assistant discovers ${installer.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `],
["Paste the API key", " above when Home Assistant asks for an encryption key."],
["Allow HA actions:", " ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."],
["Choose your tiles.", " Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."],
];
$("#install-steps").replaceChildren(...steps.map(([b, t], i) => {
const li = node("li");
li.append(node("b", b), t);
if (i === 0) {
const go = node("button", "Open Devices & services", "mini");
go.type = "button";
go.onclick = openIntegrations;
li.append(go);
}
return li;
}));
}
function resetInstaller() {
Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, ports: null, jobState: null, target: "" });
const form = $("#install-form");
form.reset();
$("#node-label").hidden = true;
$("#install-setup").hidden = false;
$("#install-progress").hidden = true;
$("#install-result").hidden = true;
$("#install-log-wrap").open = false;
$("#install-log").textContent = "";
$("#install-title").textContent = "Connect and install.";
$("#install-status").textContent = "";
$("#install-note").textContent = "";
$("#install-target").replaceChildren();
$("#target-hint").textContent = "";
$("#install-close").classList.add("quiet");
syncNode();
}
function openInstaller() {
if (installer.view !== "progress") resetInstaller();
$("#installer").showModal();
installerRefresh();
clearInterval(installer.poll);
installer.poll = setInterval(installerRefresh, 3000);
}
$("#new-screen").onclick = $("#start").onclick = openInstaller;
$("#close-install").onclick = $("#install-close").onclick = () => $("#installer").close();
$("#installer").addEventListener("close", () => {
clearInterval(installer.poll);
// A finished job is shown once; the next open starts a fresh form.
if (installer.view === "progress" && installer.jobState !== "running") installer.view = "done";
});
$("#install-target").onchange = () => { renderTargetHint(); installerRefresh(); };
$("#install-form").elements.friendly_name.oninput = syncNode;
$("#install-form").elements.name.oninput = () => { installer.nodeEdited = true; syncNode(); };
$("#edit-node").onclick = () => {
installer.nodeEdited = true;
$("#node-label").hidden = false;
$("#install-form").elements.name.focus();
};
$("#install-form").onsubmit = async (e) => {
e.preventDefault();
const form = e.target;
syncNode();
if (!form.reportValidity()) return;
const data = Object.fromEntries(new FormData(form));
$("#install-go").disabled = true;
$("#install-status").textContent = "";
try {
const result = await (await api("firmware/profiles", { method: "POST", body: JSON.stringify(data) })).json();
Object.assign(installer, { file: result.file, apiKey: result.api_key, friendly: data.friendly_name.trim(), board: data.board, target: data.target });
form.elements.wifi_password.value = "";
if (result.job) showProgress(result.job, []);
else showSaved();
} catch (err) {
$("#install-status").textContent = err.message;
} finally {
$("#install-go").disabled = false;
}
};
$("#install-retry").onclick = async () => {
try {
const { ports } = await (await api("firmware")).json();
// The board may have been replugged; a single visible port is unambiguous.
if (!ports.includes(installer.target) && ports.length === 1) installer.target = ports[0];
const job = await (await api("firmware/jobs", {
method: "POST",
body: JSON.stringify({ file: installer.file, action: "install", target: installer.target }),
})).json();
showProgress(job, []);
} catch (err) {
toast(err.message);
}
};
$("#copy-key").onclick = () => copyText(installer.apiKey || "", $("#api-key"));
window.addEventListener("beforeunload", (e) => {
if (dirty) {
e.preventDefault();
e.returnValue = "";
}
});
refresh();
// Poll only while the tab is visible; a hidden tab would otherwise keep the add-on busy.
// Live updates arrive over server-sent events; polling is the fallback while the stream is down,
// plus a full catalogue refresh every 5 minutes.
let pollTimer, lastFull = Date.now(), live = false, stream;
function applyLive(data) {
inventory = { ...inventory, ...data };
$("#connection").textContent = inventory.connected ? "● Home Assistant connected" : "Reconnecting to Home Assistant…";
$("#connection").classList.toggle("online", inventory.connected);
renderScreens();
if (selected) renderSettingsSupport();
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
}
function listen() {
if (stream || typeof EventSource === "undefined") return;
stream = new EventSource("api/events");
stream.onopen = () => { live = true; poll(); };
stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); };
stream.onerror = () => { live = false; poll(); };
}
function poll() {
clearTimeout(pollTimer);
const wait = live ? 60000 : inventory.updates?.busy ? 3000 : 10000;
pollTimer = setTimeout(async () => {
if (!document.hidden) {
const full = Date.now() - lastFull >= 300000;
if (full) lastFull = Date.now();
if (full || !live) await refresh(full);
}
poll();
}, wait);
}
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();
await refresh();
poll();
});
// Shared firmware workspace; always select a concrete profile and upload target.
let firmwarePoll;
async function firmwareRefresh(initial = false) {
try {
const data = await (await api("firmware")).json();
if (initial) {
$("#firmware-file").replaceChildren(
...data.profiles.map((p) => {
const o = node("option", p.file);
o.value = p.file;
return o;
}),
);
$("#firmware-port").replaceChildren(
...[["ota", "Wi-Fi / OTA"], ...data.ports.map((p) => [p, p])].map(
([value, text]) => {
const o = node("option", text);
o.value = value;
return o;
},
),
);
}
const running = data.job?.state === "running";
for (const id of ["validate", "build", "install"])
$("#firmware-" + id).disabled =
running || !data.available || !data.profiles.length;
$("#firmware-status").textContent = !data.available
? "The ESPHome CLI is missing. Update the app to 0.2.0."
: data.job
? `${data.job.file} · ${data.job.action} · ${data.job.state}`
: "Choose the intended profile and a USB port or IP address.";
$("#firmware-log").textContent = data.logs.join("\n");
} catch (e) {
toast(e.message);
}
}
$("#open-firmware").onclick = () => {
$("#firmware-dialog").showModal();
firmwareRefresh(true);
clearInterval(firmwarePoll);
firmwarePoll = setInterval(() => firmwareRefresh(), 3000);
};
$("#close-firmware").onclick = () => {
$("#firmware-dialog").close();
clearInterval(firmwarePoll);
};
$("#firmware-dialog").addEventListener("close", () =>
clearInterval(firmwarePoll),
);
$("#firmware-port").onchange = () =>
($("#firmware-host-label").hidden = $("#firmware-port").value !== "ota");
$("#firmware-file").onchange = () => {
$("#firmware-host").placeholder = $("#firmware-file").value.replace(
/\.yaml$/,
".local",
);
};
for (const action of ["validate", "build", "install"])
$("#firmware-" + action).onclick = async () => {
try {
await api("firmware/jobs", {
method: "POST",
body: JSON.stringify({
file: $("#firmware-file").value,
action:
action === "validate"
? "validate"
: action === "build"
? "build"
: "install",
target:
$("#firmware-port").value === "ota"
? $("#firmware-host").value.trim()
: $("#firmware-port").value,
}),
});
await firmwareRefresh();
} catch (e) {
toast(e.message);
}
};
function openSection(id) {
const section = $(id);
if (section.tagName === "DETAILS") section.open = true;
section.scrollIntoView({ behavior: "smooth", block: "start" });
}
async function inspect(entity) {
if (!selected) return;
openSection("#inspector-section");
$("#inspection-summary").textContent = "Fetching current HA status...";
try {
const data = await (
await api(`screens/${encodeURIComponent(selected)}/inspect`)
).json();
const tiles = entity
? data.tiles.filter((t) => t.entity === entity)
: data.tiles;
$("#inspection-summary").replaceChildren();
for (const tile of tiles) {
const card = node("article", undefined, "inspection-tile");
card.append(
node("strong", tile.entity),
node("p", `Status: ${tile.state}`),
);
const options =
layout.tiles.find((t) => t.entity === tile.entity)?.options || {};
card.append(
node(
"small",
`Small slider: ${options.inline === "slider" ? "yes" : "no"} · Display: ${displayNames[options.display || "standard"] || options.display} · Width: ${options.size === "wide" ? "double" : "normal"} · Control: ${controlsLabel({ entity: tile.entity, options })} · Background: ${inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`,
),
);
$("#inspection-summary").append(card);
}
$("#inspection").textContent = JSON.stringify(
entity ? tiles[0] : data,
null,
2,
);
} catch (e) {
$("#inspection-summary").textContent = e.message;
toast(e.message);
}
}
$("#inspect").onclick = () => inspect();
// ----- Alerts: the cheatsheet for esphome.<node>_show_alert, built from the inventory -----
const versionAtLeast = (version, minimum) => {
const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(v || "") || []).slice(1).map(Number);
const [a, b] = [parse(version), parse(minimum)];
if (a.length !== 3 || b.length !== 3) return false;
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
return true;
};
const yamlString = (text) => `"${String(text).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
function copyChip(text, what) {
const b = node("button", "Copy", "mini");
b.type = "button";
b.onclick = () => copyText(text, undefined, what);
return b;
}
function alertExampleYaml(action) {
const fields = inventory.alerts?.fields || [];
const lines = fields.map((f) => ` ${f.name}: ${f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : f.example === true ? "true" : f.example === false ? "false" : f.example}`);
return `action: ${action || "esphome.<device_name>_show_alert"}\ndata:\n${lines.join("\n")}`;
}
function alertWaitYaml(action) {
return [
`# Doorbell: show the alert and wait until someone presses the button.`,
`actions:`,
` - action: ${action || "esphome.<device_name>_show_alert"}`,
` data:`,
` title: "Someone is at the door"`,
` subtitle: "Door 3, back"`,
` icon: doorbell`,
` color: orange`,
` button_text: "Coming"`,
` timeout: 0`,
` flash: true`,
` - wait_for_trigger:`,
` - trigger: event`,
` event_type: ${inventory.alerts?.event || "esphome.screen_alert"}`,
` event_data:`,
` action: ok`,
` timeout: "00:05:00"`,
` - if:`,
` - condition: template`,
` value_template: "{{ wait.trigger is not none }}"`,
` then:`,
` - action: notify.notify`,
` data:`,
` message: "Someone is coming to the door."`,
].join("\n");
}
function renderAlertScreens() {
const alerts = inventory.alerts, list = $("#alerts-screen-list");
if (!alerts) return;
$("#alerts-min-firmware").textContent = alerts.min_firmware;
list.replaceChildren();
if (!inventory.screens.length) {
list.append(node("p", "No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.", "hint"));
}
for (const screen of inventory.screens) {
const row = node("div", undefined, "alert-screen");
const head = node("div", undefined, "alert-screen-head");
head.append(node("strong", screen.name));
const ready = versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action;
const badge = node("span", ready ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen", `badge ${ready ? "online" : "update"}`);
head.append(badge);
row.append(head);
for (const [label, action] of [["Show alert", screen.alert_action], ["Dismiss alert", screen.dismiss_action]]) {
const line = node("div", undefined, "copy-line");
line.append(node("span", label, "copy-label"), node("code", action || "esphome.<device_name>_show_alert"));
if (action) line.append(copyChip(action, "Action name"));
row.append(line);
}
list.append(row);
}
const select = $("#alerts-example-screen"), current = select.value;
select.replaceChildren();
for (const screen of inventory.screens.filter((s) => s.alert_action)) {
const option = node("option", screen.name);
option.value = screen.alert_action;
select.append(option);
}
if (!select.options.length) {
const option = node("option", "a screen (not paired yet)");
option.value = "";
select.append(option);
}
if ([...select.options].some((o) => o.value === current)) select.value = current;
renderAlertExamples();
}
function renderAlertExamples() {
const action = $("#alerts-example-screen").value;
$("#alerts-example").textContent = alertExampleYaml(action);
$("#alerts-wait-example").textContent = alertWaitYaml(action);
}
function alertIconCard(icon, label) {
const b = node("button", undefined, "alert-icon");
b.type = "button";
b.title = `Copy ${icon.name}`;
b.append(node("span", glyph(icon.cp), "mdi"), node("code", icon.name));
if (label) b.append(node("small", label));
b.onclick = () => copyText(icon.name, undefined, "Icon name");
return b;
}
function renderAlertIcons() {
const alerts = inventory.alerts, icons = inventory.icons;
if (!alerts || !icons) return;
const suggested = $("#alerts-suggested");
suggested.replaceChildren();
for (const icon of alerts.suggested_icons) {
const chip = node("button", undefined, "chip");
chip.type = "button";
chip.append(node("span", glyph(icon.cp), "mdi"), node("code", icon.name));
chip.onclick = () => copyText(icon.name, undefined, "Icon name");
suggested.append(chip);
}
const query = $("#alerts-icon-search").value.trim().toLowerCase();
const groups = $("#alerts-icon-groups");
groups.replaceChildren();
const all = [...icons.groups, { label: "Also available: control and weather icons", icons: alerts.extra_icons }];
let shown = 0;
for (const group of all) {
const matches = group.icons.filter((i) => !query || i.name.includes(query) || (i.label || "").toLowerCase().includes(query));
if (!matches.length) continue;
shown += matches.length;
const block = node("div", undefined, "alert-icon-group");
block.append(node("p", `${group.label} · ${matches.length}`, "alerts-subhead"));
const grid = node("div", undefined, "alert-icon-grid");
for (const icon of matches) grid.append(alertIconCard(icon, icon.label));
block.append(grid);
groups.append(block);
}
if (!shown) groups.append(node("p", `No icon for "${query}". Unknown names show the warning triangle (${alerts.fallback_icon}).`, "hint"));
}
function renderAlertColors() {
const alerts = inventory.alerts, swatches = $("#alerts-swatches");
if (!alerts) return;
swatches.replaceChildren();
const white = node("button", undefined, "swatch");
white.type = "button";
const blank = node("i");
blank.style.background = "#FFFFFF";
white.append(blank, node("span", undefined));
white.lastChild.append(node("code", "empty"), node("small", "White (default)"));
white.onclick = () => copyText("", undefined, "Empty color");
swatches.append(white);
for (const colour of alerts.colors) {
const b = node("button", undefined, "swatch");
b.type = "button";
const dot = node("i");
dot.style.background = colour.color;
const text = node("span");
text.append(node("code", colour.name), node("small", `${colour.label} · ${colour.color}`));
b.append(dot, text);
b.onclick = () => copyText(colour.name, undefined, "Color name");
swatches.append(b);
}
}
function renderAlertTables() {
const alerts = inventory.alerts;
if (!alerts) return;
const types = { string: "text", int: "number", bool: "on / off" };
const table = $("#alerts-field-table");
table.replaceChildren();
const head = node("tr");
for (const title of ["Field", "Type", "What it does", "Example", "Limit"]) head.append(node("th", title));
table.append(head);
for (const field of alerts.fields) {
const row = node("tr");
const name = node("td");
name.append(node("code", field.name), node("small", field.label));
const example = node("td");
example.append(node("code", typeof field.example === "string" ? field.example : String(field.example)));
const limit = alerts.limits.cyd[field.name];
const cells = [name, node("td", types[field.type] || field.type), node("td", field.help), example,
node("td", limit ? `CYD ${limit} · Guition ${alerts.limits.guition[field.name]} bytes` : field.type === "int" ? "0 to 86400 s" : "—")];
cells.forEach((cell, index) => cell.dataset.label = ["Field", "Type", "What it does", "Example", "Limit"][index]);
row.append(...cells);
table.append(row);
}
const endings = $("#alerts-ending-table");
endings.replaceChildren();
const endHead = node("tr");
for (const title of ["action", "When"]) endHead.append(node("th", title));
endings.append(endHead);
for (const ending of alerts.endings) {
const row = node("tr"), cell = node("td"), when = node("td", ending.label);
cell.append(node("code", ending.action));
cell.dataset.label = "action";
when.dataset.label = "When";
row.append(cell, when);
endings.append(row);
}
$("#alerts-event-name").textContent = alerts.event;
const doorbell = alerts.suggested_icons.find((i) => i.name === "doorbell");
if (doorbell) $("#alert-mock-icon").textContent = glyph(doorbell.cp);
const orange = alerts.colors.find((c) => c.name === "orange");
if (orange) $("#alert-mock").querySelector(".alert-mock-card").style.background = orange.color;
}
function renderAlerts() {
if (!inventory.alerts) {
toast("The cheatsheet is still loading; try again in a moment.");
return false;
}
renderAlertTables();
renderAlertScreens();
renderAlertIcons();
renderAlertColors();
return true;
}
$("#open-alerts").onclick = () => {
if (renderAlerts()) $("#alerts-dialog").showModal();
};
$("#close-alerts").onclick = () => $("#alerts-dialog").close();
$("#alerts-example-screen").onchange = renderAlertExamples;
$("#alerts-example-copy").onclick = () => copyText($("#alerts-example").textContent, $("#alerts-example"), "YAML");
$("#alerts-wait-copy").onclick = () => copyText($("#alerts-wait-example").textContent, $("#alerts-wait-example"), "YAML");
$("#alerts-icon-search").oninput = renderAlertIcons;
for (const b of document.querySelectorAll("#alerts-dialog [data-jump]")) {
b.onclick = () => document.getElementById(b.dataset.jump)?.scrollIntoView({ behavior: "smooth", block: "start" });
}