3e959b3df9
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>
2392 lines
108 KiB
JavaScript
2392 lines
108 KiB
JavaScript
"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" });
|
||
}
|