Send one alert to every screen, teach Claude about ESP Screens, and add a Settings page (0.2.45)

- The app listens for the Home Assistant events esp_screens_show_alert and
  esp_screens_dismiss_alert and calls each screen's show_alert/dismiss_alert action,
  screens added later included. Fields are typed like Home Assistant's ESPHome action
  schema; an unusable value is left empty instead of losing the alert.
- Settings → Claude installs an ESP Screens skill for Claude Code in Home Assistant
  (/homeassistant/.claude/skills/esp-screens) or downloads it as a zip for claude.ai.
  It is generated from the alert reference, so fields, colors and icons match the firmware.
- New screen, Firmware & USB, firmware updates, Alerts and Claude move to a Settings view;
  the header keeps one button. Unselected screens in My screens get a light grey card.

App only; firmware stays 0.2.38.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 14:52:27 +02:00
parent 3f1084a171
commit 4ff0d44312
18 changed files with 1192 additions and 165 deletions
+76 -7
View File
@@ -301,7 +301,7 @@ function renderPending() {
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}.`),
: `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? Settings → Firmware & USB → ${p.file}.`),
);
const actions = node("div", undefined, "pending-actions");
const go = node("button", "Open Devices & services", "mini");
@@ -358,14 +358,16 @@ function renderScreens() {
}
function renderUpdates() {
const u = inventory.updates;
$("#updates").hidden = !u || !inventory.screens.length;
$("#updates").hidden = !u;
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}.`;
: !inventory.screens.length
? `No screen paired yet. New screens get 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"))
@@ -379,6 +381,56 @@ $("#update-all").onclick = async () => {
toast(e.message);
}
};
// ----- Settings: a view of its own next to My screens; #settings keeps it open across a reload -----
function showView() {
const settings = location.hash === "#settings";
$("#home-view").hidden = settings;
$("#settings-view").hidden = !settings;
$("#open-settings-label").textContent = settings ? "My screens" : "Settings";
$("#open-settings-icon").textContent = settings ? "▦" : "⚙";
$("#open-settings-hint").textContent = settings ? "Tiles and top bar" : "New screen, updates, alerts, Claude";
$("#open-settings").setAttribute("aria-expanded", String(settings));
if (settings) {
renderUpdates();
renderClaude();
}
window.scrollTo(0, 0);
}
$("#open-settings").onclick = () => {
location.hash = location.hash === "#settings" ? "" : "#settings";
};
$("#close-settings").onclick = () => {
location.hash = "";
};
window.addEventListener("hashchange", showView);
showView();
function renderClaude() {
const skill = inventory.claude_skill, button = $("#claude-install"), status = $("#claude-status");
button.disabled = !skill;
if (!skill) {
status.textContent = "Loading…";
return;
}
$("#claude-path").textContent = skill.path;
button.textContent = !skill.installed ? "Install for Claude Code" : skill.current ? "Install again" : "Update the skill";
button.classList.toggle("again", skill.installed && skill.current);
status.textContent = !skill.installed ? "Not installed" : skill.current ? "● Installed" : "Installed · a newer version is ready";
status.className = !skill.installed ? "hint" : skill.current ? "badge online" : "badge update";
}
$("#claude-install").onclick = async () => {
const button = $("#claude-install");
button.disabled = true;
try {
inventory.claude_skill = await (await api("claude-skill", { method: "POST" })).json();
toast(inventory.claude_skill.restart
? "Skill installed. Restart Claude Code once so it finds the new skills folder."
: "Skill installed. Claude Code picks it up right away.");
} catch (e) {
toast(e.message);
} finally {
renderClaude();
}
};
$("#auto-update").onchange = async () => {
try {
await api("updates", {
@@ -1675,6 +1727,7 @@ async function refresh(full = true) {
: "Reconnecting to Home Assistant…";
$("#connection").classList.toggle("online", inventory.connected);
renderScreens();
if (full) renderClaude();
if (selected) renderSettingsSupport();
if (!selected && inventory.screens.length) select(inventory.screens[0].id);
if ($("#alerts-dialog").open) renderAlertScreens();
@@ -1784,7 +1837,7 @@ function renderTargetHint() {
$("#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."
? "The profile goes into the ESPHome folder. You can install later via Settings → 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.";
@@ -1887,7 +1940,7 @@ function showSaved() {
$("#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:";
"You can install once the screen is connected to the Home Assistant machine: Settings → 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;
@@ -2219,6 +2272,20 @@ function alertWaitYaml(action) {
` message: "Someone is coming to the door."`,
].join("\n");
}
// One event for every screen (app 0.2.45): an action for "Edit in YAML" of the Event action.
function alertAllYaml() {
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)) : String(f.example)}`);
return `event: ${inventory.alerts?.broadcast?.show || "esp_screens_show_alert"}\nevent_data:\n${lines.join("\n")}`;
}
function renderAlertBroadcast() {
const broadcast = inventory.alerts?.broadcast;
if (broadcast) {
$("#alerts-all-event").textContent = broadcast.show;
$("#alerts-all-dismiss").textContent = broadcast.dismiss;
}
$("#alerts-all-example").textContent = alertAllYaml();
}
function renderAlertScreens() {
const alerts = inventory.alerts, list = $("#alerts-screen-list");
if (!alerts) return;
@@ -2374,6 +2441,7 @@ function renderAlerts() {
}
renderAlertTables();
renderAlertScreens();
renderAlertBroadcast();
renderAlertIcons();
renderAlertColors();
return true;
@@ -2385,6 +2453,7 @@ $("#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-all-copy").onclick = () => copyText($("#alerts-all-example").textContent, $("#alerts-all-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" });