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
+26
View File
@@ -180,6 +180,32 @@ ESPHome fetches the latest published package and component code. So you don't
replace your own YAML with a new downloaded file. Wi-Fi, name, and keys
live outside the shared package and stay the same.
### Hardware-specific YAML overrides
Each screen also has a small local file beside its profile, for example
`kitchen.local.yaml`. Open the screen and click **Override YAML**. The editor is
intended for hardware-specific changes such as a different display controller:
```yaml
display:
- id: !extend my_display
model: ST7789V
```
ESPHome appends package lists instead of merging them, so `!extend` is what
changes the display the shared package already defines; a bare `id:` would add
a second, incomplete display and the build fails.
This file is loaded after the shared board package and is kept when the app or
firmware package updates. The editor protects the screen's name, Wi-Fi, API,
OTA and package connection. Use **Save & check** before building a custom
configuration. If the complete ESPHome profile is invalid, the firmware build
does not start.
The override is advanced configuration: the display model, dimensions, pins,
touchscreen and initialization sequence must still match the physical board.
For a similar-looking CYD, check the exact USB/controller variant first.
Make a Home Assistant backup before updates, including ESP Screen Manager and
the device's own ESPHome configurations. **Removing/reinstalling** an app is not the same
as updating; that can wipe the data folder. Keep the device name and the
+48
View File
@@ -0,0 +1,48 @@
# Test results app 0.2.61 / firmware 0.2.52 (2026-09-17)
Max built the Override YAML feature with ChatGPT and asked for a review before release. The feature is app-only:
`installation_yaml` gives every new profile a `local_overrides: !include <name>.local.yaml` package after the shared
board package, `Firmware.save_override` attaches an existing profile on the first save (spliced into `packages:`
without reformatting the rest), and the page edits the sidecar in a dialog with Save and Save & check (ESPHome
`config` of the complete profile). The review changed four things before release:
- **The example was wrong.** ESPHome 2026.6.2 `merge_config` appends package lists (`old + new`); it does not merge
items by `id`. `display: - id: my_display, model: ST7789V` therefore adds a second display without a platform and
`esphome config` fails with "'display' requires a 'platform' key". The example, placeholder and docs now use
`id: !extend my_display`, which edits the display the shared package defines. Checked with the CLI (below).
- **A too-big body was a bare 413.** The app accepts 16 KB request bodies; the override allowed 128 KB, so anything
in between made aiohttp answer a 413 HTML page and the dialog said "That didn't work". The override is now 12 KB at
most, and the guard middleware answers `HTTPRequestEntityTooLarge` as JSON like every other refusal.
- **Order of writes.** The sidecar is written before the profile gets its include, so a profile never points at a
file that isn't there. A profile whose `packages:` is the last section without a trailing newline no longer gets
the include glued to its last line.
- **Symlinks.** A symlinked `<name>.local.yaml` is refused for reading and writing; `profile()` already did that for
profiles. The Save & check poller stops when the dialog closes instead of polling `/api/firmware` for two hours.
## Automated
- Python (`.venv-portal`): `tests/test_overrides.py` 11/11 OK: hidden empty sidecar on create, attach without
reformatting and survive a reload, attach an existing profile, refuse managed sections / managed substitutions /
bad YAML without writing, accept `!extend`, normalise empty text and a missing newline, refuse 12 KB+, refuse a
symlinked sidecar without touching its target, append the include after a `packages:` section without newline,
leave a profile with a foreign `local_overrides` alone; plus the HTTP round trip (CSRF 403, GET, PUT 200, managed
section 400, oversized 413 as JSON, the sidecar stays out of the profile list).
- Full Python suite: 291 tests, 1 failure in `test_tile_icons` (`light: F0335`). That test passes on a clean copy of
HEAD; the failure comes from the binary-sensor-words session's uncommitted firmware headers in the shared working
tree, not from this change.
- `node --check app.js` clean.
## ESPHome CLI (2026.6.2)
- A profile generated by `installation_yaml` for a CYD, with the remote `packages/cyd.yaml` from GitHub and an
override of `display: - id: !extend my_display, model: ST7789V`: **Configuration is valid**, the merged display
shows `model: ST7789V`.
- Minimal local configs: an empty `{}` sidecar is valid; a bare `id:` in the override fails ("requires a 'platform'
key"); `id: !extend` merges into the package's component and a later package wins for scalars (`logger.level`).
## Not tested / for the owner
- Nothing was flashed and the Home Assistant add-on was not rebuilt locally. In the add-on: open a screen, Override
YAML, Use example, Save & check; the status should end in "The complete profile is valid".
- Existing profiles from before 0.2.61 have no include until the first save; the dialog says so ("Ready to attach").
- The example changes the display controller. It is right only for a board that really has an ST7789V.
+9
View File
@@ -1,3 +1,12 @@
## 0.2.61 (firmware 0.2.52)
Your own YAML for one screen, kept through updates.
- **Override YAML** on each screen edits a small `<screen>.local.yaml` beside the profile. It is loaded after the shared board package and stays in place when the app or the firmware package updates. New screens get an empty one; an existing profile is attached on the first save, without reformatting the rest of the file.
- The editor has an example that changes the display controller with `!extend` (ESPHome appends package lists, so a bare `id:` would add a second, incomplete display), line numbers, Tab indentation and Cmd/Ctrl-S. **Save & check** runs ESPHome's full validation of the complete profile; a build never starts from an invalid one.
- The screen's name, Wi-Fi, API, OTA, packages, external components and captive portal stay managed and are refused in the override, as are the managed substitutions. Invalid YAML is refused before anything is written, and the file is 12 KB at most.
- No firmware change: firmware stays 0.2.52.
## 0.2.60 (firmware 0.2.52)
Sliders keep their colour while a speaker plays or a blind is open.
+3
View File
@@ -26,6 +26,9 @@ After pairing via the HA ESPHome integration, choose the tiles in ESP Screens.
**Tile settings** has click behavior, larger values and mini-sliders.
**Screen settings** has brightness and standby. **Inspector** helps with
missing attributes or an offline screen. New cards require firmware 0.2.0.
Each screen also has **Override YAML** for a persistent, hardware-specific
`<screen>.local.yaml` file. It is loaded after the shared package and does not
replace the managed name, credentials, Wi-Fi, OTA or package connection.
The screen carries the same settings itself (firmware 0.2.44 or newer): hold the top bar for
about a second and a half, or put the built-in **Settings** card on a page. What is changed
+1
View File
@@ -1163,6 +1163,7 @@ packages:
ref: {REFS[board]}
files: [packages/{board}.yaml]
refresh: 0s
local_overrides: !include {name}.local.yaml
api:
encryption:
+105 -2
View File
@@ -38,6 +38,12 @@ def profile_meta(text):
'screen': ours, 'api_key': key if isinstance(key, str) else None}
class Firmware:
OVERRIDE_SUFFIX = '.local.yaml'
OVERRIDE_LIMIT = 12 * 1024 # bytes; the request body limit is 16 KB and JSON escaping grows the text
PROTECTED_OVERRIDE_KEYS = frozenset({'esphome', 'api', 'ota', 'wifi', 'packages',
'external_components', 'captive_portal'})
PROTECTED_SUBSTITUTIONS = frozenset({'DEVICE_NAME', 'DEVICE_FRIENDLY_NAME', 'SCREEN_FIRMWARE_VERSION'})
def __init__(self, root, data):
self.root, self.data = Path(root).resolve(), Path(data)
self.job = None
@@ -50,7 +56,8 @@ class Firmware:
def profiles(self):
if not self.root.exists(): return []
return [{'file': p.name} for p in sorted(self.root.glob('*.yaml'))
if p.name != 'secrets.yaml' and p.is_file() and not p.is_symlink()]
if p.name != 'secrets.yaml' and not p.name.endswith(self.OVERRIDE_SUFFIX)
and p.is_file() and not p.is_symlink()]
def profile_names(self):
"""{file: {'node': esphome name, 'friendly': friendly name}} for every readable profile.
@@ -144,22 +151,115 @@ class Firmware:
path.write_text(text)
def create(self, data):
"""Write the device profile (unique API/OTA keys) and any missing wifi secrets."""
"""Write the device profile, its empty local override, and any missing wifi secrets."""
content = installation_yaml(data)
self.root.mkdir(parents=True,exist_ok=True)
profile = self.root / (data['name']+'.yaml')
if profile.exists() or profile.is_symlink():
raise ValueError('This name already exists. Use the existing profile for updates.')
override = self.root / (data['name'] + self.OVERRIDE_SUFFIX)
if override.exists() or override.is_symlink():
raise ValueError('This name already has a local override. Restore or remove it before creating a new screen with this name.')
self.store_wifi(data)
try:
with profile.open('x') as f:
os.chmod(profile,0o600);f.write(content)
except FileExistsError:
raise ValueError('This name already exists. Use the existing profile for updates.')
try:
with override.open('x') as f:
os.chmod(override, 0o600); f.write('{}\n')
except FileExistsError:
raise ValueError('This name already has a local override. Restore or remove it before creating a new screen with this name.')
key = yaml.load(content, Loader=LenientLoader)['api']['encryption']['key']
# The key is what Home Assistant asks for when pairing; the page shows it once.
return {'file': profile.name, 'node': data['name'], 'api_key': key}
def _override_path(self, name):
profile = self.profile(name)
path = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
if path.is_symlink():
raise ValueError('The local override is a symbolic link; replace it with a regular file first.')
return profile, path
def _atomic_write(self, path, text):
temporary = path.with_name(path.name + '.tmp')
with temporary.open('w', encoding='utf8') as handle:
os.chmod(temporary, 0o600)
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(path)
def _ensure_override_include(self, profile):
"""Attach an existing profile to its sidecar without reformatting user YAML."""
filename = profile.stem + self.OVERRIDE_SUFFIX
text = profile.read_text()
if re.search(rf'(?m)^\s*local_overrides:\s*!include\s+{re.escape(filename)}\s*$', text):
return False
if re.search(r'(?m)^\s*local_overrides\s*:', text):
raise ValueError('This profile already has a different local_overrides include.')
try:
parsed = yaml.load(text, Loader=LenientLoader)
except yaml.YAMLError as error:
raise ValueError("The screen profile isn't valid YAML; fix it before adding an override.") from error
packages = parsed.get('packages') if isinstance(parsed, dict) else None
if not isinstance(packages, dict):
raise ValueError("This profile has no packages section that ESP Screens can extend.")
match = re.search(r'(?m)^packages:\s*\n', text)
if not match:
raise ValueError("This profile has no packages section that ESP Screens can extend.")
next_top = re.search(r'(?m)^[^\s#][^\n]*\n', text[match.end():])
if not next_top and not text.endswith('\n'):
text += '\n'
end = match.end() + next_top.start() if next_top else len(text)
addition = f' local_overrides: !include {filename}\n'
self._atomic_write(profile, text[:end] + addition + text[end:])
self._names.pop(profile.name, None)
return True
def _validate_override(self, content):
if not isinstance(content, str):
raise ValueError('Enter YAML text first.')
if len(content.encode('utf8')) > self.OVERRIDE_LIMIT:
raise ValueError('The override is too large. Keep it below 12 KB.')
if not content.strip():
content = '{}\n'
try:
parsed = yaml.load(content, Loader=LenientLoader)
except yaml.YAMLError as error:
problem = getattr(error, 'problem', None) or 'invalid YAML'
mark = getattr(error, 'problem_mark', None)
where = f' on line {mark.line + 1}' if mark else ''
raise ValueError(f'YAML error{where}: {problem}.') from error
if not isinstance(parsed, dict):
raise ValueError('The override must contain a YAML object, for example display: or substitutions:.')
protected = sorted(set(parsed) & self.PROTECTED_OVERRIDE_KEYS)
if protected:
raise ValueError('These sections stay managed by ESP Screens: ' + ', '.join(protected) + '.')
substitutions = parsed.get('substitutions')
if isinstance(substitutions, dict):
protected = sorted(set(substitutions) & self.PROTECTED_SUBSTITUTIONS)
if protected:
raise ValueError('These substitutions stay managed by ESP Screens: ' + ', '.join(protected) + '.')
return content if content.endswith('\n') else content + '\n'
def override(self, name):
profile, path = self._override_path(name)
attached = bool(re.search(rf'(?m)^\s*local_overrides:\s*!include\s+{re.escape(path.name)}\s*$',
profile.read_text()))
content = path.read_text() if path.exists() else '{}\n'
return {'file': profile.name, 'override_file': path.name, 'attached': attached,
'exists': path.exists(), 'content': content}
def save_override(self, name, content):
profile, path = self._override_path(name)
content = self._validate_override(content)
# The sidecar first: a profile must never include a file that isn't there.
self._atomic_write(path, content)
self._ensure_override_include(profile)
return self.override(profile.name)
def install(self, data):
"""Profile plus, when a USB port is chosen, the build and flash in one go.
@@ -213,6 +313,9 @@ class Firmware:
node=yaml.compose(secret_file.read_text())
if isinstance(node,yaml.MappingNode):
for _,v in node.value:collect(v)
override_file = self.root / (profile.stem + self.OVERRIDE_SUFFIX)
if override_file.exists() and not override_file.is_symlink():
collect(yaml.compose(override_file.read_text()))
self.logs.clear()
self.job={'file':profile.name,'action':action,'target':target,'state':'running','started':time.time()}
self.task=asyncio.create_task(self.run(profile,action,target))
+11
View File
@@ -1243,6 +1243,8 @@ def create_app(manager, development=False):
response = await handler(request)
except ValueError as error:
return web.json_response({'error': str(error)}, status=400)
except web.HTTPRequestEntityTooLarge:
return web.json_response({'error': 'That is too much text for one request. Keep it below 12 KB.'}, status=413)
except (TypeError, KeyError):
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
if response.prepared:
@@ -1363,6 +1365,13 @@ def create_app(manager, development=False):
headers={'Content-Disposition': f'attachment; filename="{claude_skill.NAME}.zip"'})
async def firmware_status(request): return web.json_response(manager.firmware.status())
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json()))
async def firmware_override(request):
return web.json_response(manager.firmware.override(request.match_info['file']))
async def firmware_override_save(request):
data = await request.json()
if not isinstance(data, dict):
raise ValueError('Invalid override data.')
return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content')))
async def firmware_create(request):
# Profile, missing wifi secrets and (with a USB port) the build and flash in one request.
return web.json_response(manager.firmware.install(await request.json()))
@@ -1378,6 +1387,8 @@ def create_app(manager, development=False):
app.router.add_put('/api/updates', update_settings)
app.router.add_get('/api/firmware', firmware_status)
app.router.add_post('/api/firmware/jobs', firmware_start)
app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override)
app.router.add_put('/api/firmware/profiles/{file}/override', firmware_override_save)
app.router.add_post('/api/firmware/profiles', firmware_create)
app.router.add_get('/', index)
app.router.add_get('/api/inventory', inventory)
+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;
}
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.60"
version: "0.2.61"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+160
View File
@@ -0,0 +1,160 @@
"""Per-device YAML overrides stay local, validated, and out of the profile list."""
import importlib.util
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
from firmware import Firmware # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
from test_screen_owned_settings import fake_ha
class OverrideTests(unittest.TestCase):
def make_firmware(self, tmp):
f = Firmware(Path(tmp) / 'esphome', Path(tmp) / 'data')
f.create({'board': 'cyd', 'name': 'kitchen', 'friendly_name': 'Kitchen',
'wifi_ssid': 'ssid', 'wifi_password': 'password'})
return f
def test_new_profiles_get_a_hidden_empty_sidecar(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
profile = f.profile('kitchen.yaml').read_text()
self.assertIn('local_overrides: !include kitchen.local.yaml', profile)
self.assertEqual((f.root / 'kitchen.local.yaml').read_text(), '{}\n')
self.assertEqual(f.profiles(), [{'file': 'kitchen.yaml'}])
def test_override_is_attached_without_reformatting_and_survives_reload(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
original = f.profile('kitchen.yaml').read_text()
content = 'display:\n - id: my_display\n model: ST7789V\n'
saved = f.save_override('kitchen.yaml', content)
self.assertEqual(saved['content'], content)
self.assertTrue(saved['attached'])
self.assertEqual(f.profile('kitchen.yaml').read_text(), original)
fresh = Firmware(f.root, f.data)
self.assertEqual(fresh.override('kitchen.yaml')['content'], content)
self.assertEqual(fresh.profiles(), [{'file': 'kitchen.yaml'}])
def test_existing_profile_gets_sidecar_on_first_save(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / 'esphome'
root.mkdir()
profile = root / 'existing.yaml'
profile.write_text('esphome:\n name: existing\n\npackages:\n display: !include board.yaml\n\napi:\n')
f = Firmware(root, Path(tmp) / 'data')
f.save_override('existing.yaml', 'display:\n - id: my_display\n model: ST7789V\n')
text = profile.read_text()
self.assertIn(' local_overrides: !include existing.local.yaml\n', text)
self.assertIn('\napi:\n', text)
def test_managed_sections_and_bad_yaml_are_rejected_without_writing(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
path = f.root / 'kitchen.local.yaml'
before = path.read_text()
for content in ('api:\n encryption: {}\n', 'substitutions:\n DEVICE_NAME: bad\n', 'display: [\n'):
with self.assertRaises(ValueError):
f.save_override('kitchen.yaml', content)
self.assertEqual(path.read_text(), before)
def test_extend_of_a_package_component_is_accepted(self):
# ESPHome appends package lists, so the way to change the shared display is !extend; LenientLoader
# reads the tag as None and the file is stored as typed.
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
content = 'display:\n - id: !extend my_display\n model: ST7789V\n'
self.assertEqual(f.save_override('kitchen.yaml', content)['content'], content)
def test_empty_text_and_missing_newline_are_normalised(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
self.assertEqual(f.save_override('kitchen.yaml', ' \n')['content'], '{}\n')
self.assertEqual(f.save_override('kitchen.yaml', 'logger:\n level: DEBUG')['content'], 'logger:\n level: DEBUG\n')
def test_oversized_override_is_refused(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
big = 'logger:\n' + ''.join(f' # padding line {i}\n' for i in range(700))
self.assertGreater(len(big), Firmware.OVERRIDE_LIMIT)
with self.assertRaisesRegex(ValueError, 'too large'):
f.save_override('kitchen.yaml', big)
def test_a_symlinked_sidecar_is_never_read_or_replaced(self):
with tempfile.TemporaryDirectory() as tmp:
f = self.make_firmware(tmp)
secret = Path(tmp) / 'secret.txt'
secret.write_text('wifi_password: hunter2\n')
link = f.root / 'kitchen.local.yaml'
link.unlink()
link.symlink_to(secret)
with self.assertRaises(ValueError):
f.override('kitchen.yaml')
with self.assertRaises(ValueError):
f.save_override('kitchen.yaml', 'logger:\n level: DEBUG\n')
self.assertEqual(secret.read_text(), 'wifi_password: hunter2\n')
self.assertTrue(link.is_symlink())
def test_include_is_appended_when_packages_is_the_last_section_without_newline(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / 'esphome'
root.mkdir()
profile = root / 'tail.yaml'
profile.write_text('esphome:\n name: tail\npackages:\n display: !include board.yaml')
f = Firmware(root, Path(tmp) / 'data')
f.save_override('tail.yaml', 'logger:\n level: DEBUG\n')
self.assertEqual(profile.read_text(),
'esphome:\n name: tail\npackages:\n display: !include board.yaml\n local_overrides: !include tail.local.yaml\n')
def test_a_foreign_local_overrides_include_leaves_the_profile_alone(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / 'esphome'
root.mkdir()
profile = root / 'other.yaml'
text = 'esphome:\n name: other\npackages:\n local_overrides: !include elsewhere.yaml\n'
profile.write_text(text)
f = Firmware(root, Path(tmp) / 'data')
with self.assertRaises(ValueError):
f.save_override('other.yaml', 'logger:\n level: DEBUG\n')
self.assertEqual(profile.read_text(), text)
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class OverrideRoutes(unittest.IsolatedAsyncioTestCase):
async def test_the_page_reads_saves_and_is_told_when_the_text_is_too_big(self):
with tempfile.TemporaryDirectory() as tmp:
with patch.dict(os.environ, {'ESPHOME_CONFIG': str(Path(tmp) / 'esphome')}):
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
m.firmware.create({'board': 'cyd', 'name': 'kitchen', 'friendly_name': 'Kitchen',
'wifi_ssid': 'ssid', 'wifi_password': 'password'})
async with TestClient(TestServer(create_app(m, True))) as client:
inventory = await (await client.get('/api/inventory')).json()
headers = {'X-Screen-CSRF': inventory['csrf']}
url = '/api/firmware/profiles/kitchen.yaml/override'
data = await (await client.get(url)).json()
self.assertEqual((data['override_file'], data['attached'], data['content']), ('kitchen.local.yaml', True, '{}\n'))
self.assertEqual((await client.put(url, json={'content': 'logger: {}\n'})).status, 403, 'CSRF')
response = await client.put(url, json={'content': 'display:\n - id: !extend my_display\n model: ST7789V\n'}, headers=headers)
self.assertEqual(response.status, 200)
self.assertEqual((await response.json())['content'], 'display:\n - id: !extend my_display\n model: ST7789V\n')
response = await client.put(url, json={'content': 'api:\n reboot_timeout: 0s\n'}, headers=headers)
self.assertEqual(response.status, 400)
self.assertIn('api', (await response.json())['error'])
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 4000}, headers=headers)
self.assertEqual(response.status, 413)
self.assertIn('12 KB', (await response.json())['error'])
self.assertEqual((await (await client.get(url)).json())['content'], 'display:\n - id: !extend my_display\n model: ST7789V\n')
self.assertEqual((await (await client.get('/api/firmware')).json())['profiles'], [{'file': 'kitchen.yaml'}])
if __name__ == '__main__':
unittest.main()