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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1163,6 +1163,7 @@ packages:
|
||||
ref: {REFS[board]}
|
||||
files: [packages/{board}.yaml]
|
||||
refresh: 0s
|
||||
local_overrides: !include {name}.local.yaml
|
||||
|
||||
api:
|
||||
encryption:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user