Give each screen its own YAML override that survives updates (0.2.61)

Override YAML on a screen edits <name>.local.yaml, a package loaded after
the shared board package. New profiles get an empty one; an existing
profile is attached on the first save without reformatting the file. The
editor validates YAML, refuses the managed sections and substitutions,
and Save & check runs ESPHome's full validation of the complete profile.

The example uses !extend: ESPHome appends package lists instead of
merging them by id, so a bare id added a second display without a
platform and failed the build. An override is 12 KB at most and a
too-large body answers as JSON instead of a bare 413. The sidecar is
written before the profile includes it, and a symlinked sidecar is
refused. Firmware stays 0.2.52.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-17 08:23:13 +02:00
parent 87b2082f81
commit 8a587303d7
12 changed files with 694 additions and 4 deletions
+156
View File
@@ -73,6 +73,7 @@ function select(id) {
dirty = false;
$("#title").value = layout.title;
$("#screen-name").textContent = screen.name;
$("#open-override").disabled = !screen.update?.profile;
$("#editor").hidden = false;
$("#empty").hidden = true;
$("#dirty").textContent = "All saved";
@@ -515,6 +516,17 @@ function renderScreens() {
b.append(node("strong", screen.name), meta);
b.onclick = () => select(screen.id);
item.append(b);
const override = node("button", "Override YAML", "mini quiet");
override.type = "button";
override.disabled = !screen.update?.profile;
override.title = screen.update?.profile ? "Hardware-specific YAML for this screen" : "No ESPHome profile found";
override.onclick = (event) => {
event.stopPropagation();
openOverride(screen.update?.profile, screen.name);
};
const overrideRow = node("div", undefined, "screen-override");
overrideRow.append(override);
item.append(overrideRow);
const update = renderUpdate(screen);
if (update) item.append(update);
$("#screens").append(item);
@@ -528,6 +540,150 @@ function renderScreens() {
}
renderUpdates();
}
// ----- Per-screen local YAML overrides -----
const OVERRIDE_EXAMPLE = `# Hardware-specific changes for this screen.
# This file is kept when the shared firmware package updates.
# Do not add esphome:, api:, ota:, wifi: or packages: here.
# Example: a CYD with the ST7789V display controller.
# "!extend" changes the display the shared package already defines;
# a bare id would add a second, incomplete display and fail the build.
display:
- id: !extend my_display
model: ST7789V
`;
const overrideEditor = $("#override-editor"), overrideGutter = $("#override-gutter");
let overrideProfile = null;
function updateOverrideEditor() {
if (!overrideEditor || !overrideGutter) return;
const lines = overrideEditor.value.split("\n").length;
overrideGutter.textContent = Array.from({ length: lines }, (_, i) => i + 1).join("\n");
$("#override-count").textContent = `${overrideEditor.value.length} characters · ${lines} line${lines === 1 ? "" : "s"}`;
}
function setOverrideStatus(message, kind = "") {
const status = $("#override-status");
status.textContent = message;
status.className = `override-status ${kind}`;
}
function setOverrideContent(content) {
overrideEditor.value = content || "";
updateOverrideEditor();
overrideEditor.scrollTop = 0;
overrideGutter.scrollTop = 0;
}
async function openOverride(profile, friendly = "") {
if (!profile) {
toast("No ESPHome profile was found for this screen.");
return;
}
overrideProfile = profile;
$("#override-title").textContent = `Override YAML${friendly ? ` · ${friendly}` : ""}`;
setOverrideStatus("Loading…");
$("#override-dialog").showModal();
try {
const data = await (await api(`firmware/profiles/${encodeURIComponent(profile)}/override`)).json();
$("#override-file").textContent = data.override_file;
$("#override-state").textContent = data.attached ? "Active" : "Ready to attach";
$("#override-state").className = `badge${data.attached ? " online" : ""}`;
setOverrideContent(data.exists && data.content !== "{}\n" ? data.content : "");
setOverrideStatus(data.attached ? "Changes here apply on the next build." : "Save once to attach this file to the profile.");
} catch (error) {
setOverrideStatus(error.message, "error");
}
}
function overridePayload() {
return { content: overrideEditor.value };
}
async function saveOverride(runCheck = false) {
if (!overrideProfile) return;
const save = $("#override-save"), check = $("#override-check");
save.disabled = true; check.disabled = true;
setOverrideStatus("Checking and saving…");
try {
const response = await api(`firmware/profiles/${encodeURIComponent(overrideProfile)}/override`, {
method: "PUT", body: JSON.stringify(overridePayload()),
});
const data = await response.json();
$("#override-file").textContent = data.override_file;
$("#override-state").textContent = "Active";
$("#override-state").className = "badge online";
if (!runCheck) {
setOverrideStatus("Saved. The override is kept during firmware updates.", "ok");
toast("Override YAML saved.");
return;
}
setOverrideStatus("Saved. ESPHome is checking the complete profile…");
const job = await (await api("firmware/jobs", {
method: "POST", body: JSON.stringify({ file: overrideProfile, action: "validate" }),
})).json();
pollOverrideCheck(job);
} catch (error) {
setOverrideStatus(error.message, "error");
} finally {
save.disabled = false; check.disabled = false;
}
}
function pollOverrideCheck(job) {
const started = Date.now(), profile = overrideProfile;
const poll = async () => {
if (overrideProfile !== profile) return; // the dialog closed or shows another screen
try {
const data = await (await api("firmware")).json();
const current = data.job;
if (current && current.file === profile && current.state === "running") {
setOverrideStatus(`ESPHome is checking the profile… ${current.stage || ""}`.trim());
} else if (current && current.file === profile && current.state === "success") {
setOverrideStatus("The complete profile is valid. Safe to build and install.", "ok");
return;
} else if (current && current.file === profile && current.state === "failed") {
const error = (data.logs || []).filter((line) => /error|failed/i.test(line)).pop();
setOverrideStatus(error || "ESPHome rejected the complete profile. See Firmware & USB for the full log.", "error");
return;
}
if (Date.now() - started < 7200000) setTimeout(poll, 1200);
} catch (error) {
setOverrideStatus(error.message, "error");
}
};
poll();
}
$("#open-override").onclick = () => {
const screen = inventory.screens.find((entry) => entry.id === selected);
openOverride(screen?.update?.profile, screen?.name);
};
$("#close-override").onclick = () => $("#override-dialog").close();
$("#override-dialog").addEventListener("close", () => { overrideProfile = null; });
$("#override-save").onclick = () => saveOverride(false);
$("#override-check").onclick = () => saveOverride(true);
$("#override-example").onclick = () => {
if (!overrideEditor.value.trim() || confirm("Replace the current text with the example?")) {
setOverrideContent(OVERRIDE_EXAMPLE);
setOverrideStatus("Example loaded. Save it when you are ready.");
overrideEditor.focus();
}
};
$("#override-empty").onclick = () => {
if (!overrideEditor.value.trim() || confirm("Clear the local override?")) {
setOverrideContent("");
setOverrideStatus("The override will be empty after you save.");
overrideEditor.focus();
}
};
overrideEditor.addEventListener("input", updateOverrideEditor);
overrideEditor.addEventListener("scroll", () => { overrideGutter.scrollTop = overrideEditor.scrollTop; });
overrideEditor.addEventListener("keydown", (event) => {
if (event.key === "Tab") {
event.preventDefault();
const start = overrideEditor.selectionStart, end = overrideEditor.selectionEnd;
overrideEditor.setRangeText(" ", start, end, "end");
updateOverrideEditor();
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
saveOverride(false);
}
});
updateOverrideEditor();
function renderUpdates() {
const u = inventory.updates;
$("#updates").hidden = !u;
+43 -1
View File
@@ -57,7 +57,10 @@
<p class="eyebrow" id="screen-name"></p>
<h2>Make it your screen</h2>
</div>
<span id="delivery" class="badge"></span>
<div class="editor-head-actions">
<button id="open-override" class="quiet mini" type="button">Override YAML</button>
<span id="delivery" class="badge"></span>
</div>
</div>
<section class="topbar-editor" id="topbar" aria-labelledby="topbar-title">
<div class="section-title">
@@ -370,6 +373,45 @@
<p id="firmware-status" role="status"></p>
<pre id="firmware-log"></pre>
</dialog>
<dialog id="override-dialog" class="override-dialog" aria-labelledby="override-title">
<div class="dialog-head">
<div>
<p class="eyebrow">ADVANCED · LOCAL OVERRIDE</p>
<h2 id="override-title">Override YAML</h2>
</div>
<button id="close-override" class="quiet" type="button" aria-label="Close">✕</button>
</div>
<p class="override-intro">
This small file is yours. It is loaded after the shared screen package and stays in place when
ESP Screen Manager updates. Change hardware-specific details here, such as a display model.
</p>
<div class="override-notice">
<span class="override-icon" aria-hidden="true">⌘</span>
<div>
<strong>What is safe to change?</strong>
<span>Display, touchscreen, substitutions, pins and other device details. Name, Wi-Fi, API, OTA and package links remain managed.</span>
</div>
</div>
<div class="override-file-row">
<span id="override-file" class="override-file"></span>
<span id="override-state" class="badge"></span>
</div>
<div class="yaml-editor" id="yaml-editor-wrap">
<div id="override-gutter" class="yaml-gutter" aria-hidden="true">1</div>
<textarea id="override-editor" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off"
aria-label="Local override YAML" placeholder="# Example\ndisplay:\n - id: !extend my_display\n model: ST7789V"></textarea>
</div>
<div class="override-tools">
<button id="override-example" class="quiet mini" type="button">Use example</button>
<button id="override-empty" class="quiet mini" type="button">Clear</button>
<span id="override-count" class="hint"></span>
</div>
<p id="override-status" class="override-status" role="status"></p>
<div class="yaml-actions override-actions">
<button id="override-check" class="quiet" type="button">Save & check</button>
<button id="override-save" type="button">Save override</button>
</div>
</dialog>
<dialog id="tile-sheet" class="tile-sheet" aria-label="Configure tile"></dialog>
<dialog id="topbar-sheet" class="tile-sheet" aria-label="Top bar"></dialog>
<dialog id="alerts-dialog" class="alerts" aria-label="Alerts">
+131
View File
@@ -192,6 +192,12 @@ aside h2 {
.screen-main .dot.online {
color: #2e9d62;
}
.screen-override {
padding: 0 14px 12px;
}
.screen-override button {
width: 100%;
}
.screen-update {
display: flex;
align-items: center;
@@ -413,6 +419,13 @@ body.dragging {
align-items: start;
margin-bottom: 15px;
}
.editor-head-actions {
display: flex;
align-items: center;
justify-content: end;
gap: 8px;
flex-wrap: wrap;
}
.editor-head .badge {
max-width: 240px;
}
@@ -678,6 +691,10 @@ dialog label {
.editor-head {
display: block;
}
.editor-head-actions {
justify-content: start;
margin-top: 10px;
}
.editor-head .badge {
margin-bottom: 10px;
}
@@ -942,6 +959,120 @@ pre {
max-width: 780px;
width: 90vw;
}
.override-dialog {
max-width: 820px;
width: 92vw;
padding: 26px;
}
.override-intro {
max-width: 700px;
margin-bottom: 14px;
}
.override-notice {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 12px 14px;
border: 1px solid #d8e8ff;
border-radius: 13px;
background: #f4f8ff;
color: #34506e;
font-size: 13px;
}
.override-notice strong,
.override-notice span {
display: block;
}
.override-notice strong {
color: #233c5a;
margin-bottom: 2px;
}
.override-icon {
display: grid;
place-items: center;
width: 25px;
height: 25px;
flex: 0 0 25px;
border-radius: 8px;
background: #dceaff;
color: #286cc2;
font-weight: 750;
}
.override-file-row {
display: flex;
align-items: center;
gap: 10px;
margin: 18px 0 8px;
}
.override-file {
font: 600 13px ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--ink);
overflow-wrap: anywhere;
}
.yaml-editor {
display: grid;
grid-template-columns: 43px minmax(0, 1fr);
min-height: 340px;
border: 1px solid #bac9dc;
border-radius: 14px;
overflow: hidden;
background: #142131;
box-shadow: 0 8px 24px #1d2d4014;
}
.yaml-gutter {
padding: 15px 10px 15px 0;
overflow: hidden;
background: #1b2b3e;
color: #71849b;
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
text-align: right;
user-select: none;
white-space: pre;
}
#override-editor {
display: block;
width: 100%;
min-height: 340px;
resize: vertical;
border: 0;
border-left: 1px solid #2e4158;
border-radius: 0;
outline: 0;
padding: 15px;
background: #142131;
color: #eaf2fc;
caret-color: #8ec5ff;
font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
tab-size: 2;
white-space: pre;
}
#override-editor::selection {
background: #3978bd80;
}
.override-tools {
display: flex;
align-items: center;
gap: 8px;
margin-top: 9px;
}
.override-tools .hint {
margin-left: auto;
}
.override-status {
min-height: 20px;
margin: 12px 0 0;
font-size: 13px;
}
.override-status.ok {
color: #21704b;
}
.override-status.error {
color: #a53f3f;
}
.override-actions {
justify-content: flex-end;
margin-top: 8px;
}
.tile-actions {
align-self: start;
}