From 747ec4cceb235050b7d67813d8af3bb9c3f369c3 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sat, 12 Sep 2026 12:23:53 +0200 Subject: [PATCH] Improve installer readability and validation feedback --- screen_manager/CHANGELOG.md | 5 + screen_manager/app/server.py | 4 +- screen_manager/app/static/app.js | 363 ++++++++++++++--- screen_manager/app/static/index.html | 246 +++++++++++- screen_manager/app/static/style.css | 566 ++++++++++++++++++++++++++- screen_manager/config.yaml | 2 +- 6 files changed, 1115 insertions(+), 71 deletions(-) diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index 2cdc9d0..3bcf4ff 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.1.1 + +- Duidelijke foutmelding bij een ongeldige naam, te veel tegels of dubbele entiteiten. +- Updatepad met behoud van bestaande schermindelingen. + # 0.1.0 - Eerste ESP Screen Manager met Home Assistant Ingress. diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py index e60a656..f854af7 100644 --- a/screen_manager/app/server.py +++ b/screen_manager/app/server.py @@ -206,7 +206,9 @@ def create_app(manager, development=False): raise web.HTTPForbidden(text='Vernieuw deze pagina en probeer opnieuw.') try: response = await handler(request) - except (ValueError, TypeError, KeyError): + except ValueError as error: + return web.json_response({'error': str(error)}, status=400) + except (TypeError, KeyError): return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400) response.headers['Cache-Control'] = 'no-store' response.headers['X-Content-Type-Options'] = 'nosniff' diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js index 00d4f23..318aabd 100644 --- a/screen_manager/app/static/app.js +++ b/screen_manager/app/static/app.js @@ -1,64 +1,335 @@ -'use strict'; -const $ = s => document.querySelector(s); -let inventory = {screens: [], entities: []}, selected = null, layout = null, dirty = false, filter = '', dragIndex = -1, busy = false; -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) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 5000); } +"use strict"; +const $ = (s) => document.querySelector(s); +let inventory = { screens: [], entities: [] }, + selected = null, + layout = null, + dirty = false, + filter = "", + dragIndex = -1, + busy = false; +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) { + $("#toast").textContent = message; + $("#toast").hidden = false; + clearTimeout(toast.timer); + toast.timer = setTimeout(() => ($("#toast").hidden = true), 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 = 'Dit lukte niet. Vernieuw de pagina en probeer opnieuw.'; try { message = (await response.json()).error || message; } catch {} throw new Error(message); } + const response = await fetch(`api/${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + "X-Screen-CSRF": inventory.csrf || "", + ...options.headers, + }, + }); + if (!response.ok) { + let message = "Dit lukte niet. Vernieuw de pagina en probeer opnieuw."; + try { + message = (await response.json()).error || message; + } catch {} + throw new Error(message); + } return response; } -function markDirty() { dirty = true; $('#dirty').textContent = 'Niet-opgeslagen wijzigingen'; $('#save-detail').textContent = 'Klik op opslaan om je scherm bij te werken.'; } +function markDirty() { + dirty = true; + $("#dirty").textContent = "Niet-opgeslagen wijzigingen"; + $("#save-detail").textContent = "Klik op opslaan om je scherm bij te werken."; +} function select(id) { - if (id !== selected && dirty && !confirm('Je hebt niet-opgeslagen wijzigingen. Toch een ander scherm openen?')) return; - selected = id; const screen = inventory.screens.find(s => s.id === id); if (!screen) return; - layout = structuredClone(screen.layout); dirty = false; $('#title').value = layout.title; $('#screen-name').textContent = screen.name; - $('#editor').hidden = false; $('#empty').hidden = true; $('#dirty').textContent = 'Alles opgeslagen'; $('#save-detail').textContent = 'Aanpassen kan zonder opnieuw flashen.'; - renderScreens(); renderTiles(); renderResults(); + if ( + id !== selected && + dirty && + !confirm( + "Je hebt niet-opgeslagen wijzigingen. Toch een ander scherm openen?", + ) + ) + return; + selected = id; + const screen = inventory.screens.find((s) => s.id === id); + if (!screen) return; + layout = structuredClone(screen.layout); + dirty = false; + $("#title").value = layout.title; + $("#screen-name").textContent = screen.name; + $("#editor").hidden = false; + $("#empty").hidden = true; + $("#dirty").textContent = "Alles opgeslagen"; + $("#save-detail").textContent = "Aanpassen kan zonder opnieuw flashen."; + renderScreens(); + renderTiles(); + renderResults(); } function renderScreens() { - $('#screens').replaceChildren(); - for (const screen of inventory.screens) { const b = node('button', undefined, `screen ${screen.id === selected ? 'selected' : ''}`); b.append(node('strong', screen.name), node('small', `${screen.online ? '● Online' : '○ Offline'}${screen.area ? ' · '+screen.area : ''} · firmware ${screen.firmware || 'onbekend'}`)); b.onclick = () => select(screen.id); $('#screens').append(b); } - const screen = inventory.screens.find(s => s.id === selected); - if (screen) { $('#delivery').textContent = screen.online ? `${screen.delivery} · ${screen.status}` : 'Offline · wijzigingen worden bewaard'; $('#delivery').classList.toggle('online', screen.online); } + $("#screens").replaceChildren(); + for (const screen of inventory.screens) { + const b = node( + "button", + undefined, + `screen ${screen.id === selected ? "selected" : ""}`, + ); + b.append( + node("strong", screen.name), + node( + "small", + `${screen.online ? "● Online" : "○ Offline"}${screen.area ? " · " + screen.area : ""} · firmware ${screen.firmware || "onbekend"}`, + ), + ); + b.onclick = () => select(screen.id); + $("#screens").append(b); + } + const screen = inventory.screens.find((s) => s.id === selected); + if (screen) { + $("#delivery").textContent = screen.online + ? `${screen.delivery} · ${screen.status}` + : "Offline · wijzigingen worden bewaard"; + $("#delivery").classList.toggle("online", screen.online); + } +} +function move(from, to) { + if (to < 0 || to >= layout.tiles.length) return; + const [tile] = layout.tiles.splice(from, 1); + layout.tiles.splice(to, 0, tile); + markDirty(); + renderTiles(); } -function move(from, to) { if (to < 0 || to >= layout.tiles.length) return; const [tile] = layout.tiles.splice(from, 1); layout.tiles.splice(to, 0, tile); markDirty(); renderTiles(); } function renderTiles() { - $('#tiles').replaceChildren(); $('#count').textContent = `${layout.tiles.length} / 10`; $('#no-tiles').hidden = layout.tiles.length > 0; + $("#tiles").replaceChildren(); + $("#count").textContent = `${layout.tiles.length} / 10`; + $("#no-tiles").hidden = layout.tiles.length > 0; layout.tiles.forEach((tile, i) => { - if (i === 6) $('#tiles').append(node('li', 'Pagina 2', 'page-break')); - const li = node('li', undefined, 'tile'); li.draggable = true; - li.ondragstart = e => { if (e.target instanceof HTMLInputElement) { e.preventDefault(); return; } dragIndex = i; e.dataTransfer.setData('text/plain', String(i)); li.classList.add('dragging'); }; - li.ondragend = () => { dragIndex = -1; li.classList.remove('dragging'); }; - li.ondragover = e => { if (dragIndex >= 0) e.preventDefault(); }; - li.ondrop = e => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, i); }; - const content = node('div'), input = node('input'); input.value = tile.name; input.placeholder = inventory.entities.find(e => e.id === tile.entity)?.name || tile.entity; input.maxLength = 60; input.setAttribute('aria-label', `Naam voor tegel ${i+1}`); input.oninput = () => { tile.name = input.value; markDirty(); }; content.append(input, node('small', tile.entity)); - const actions = node('div', undefined, 'tile-actions'); for (const [label, title, action, disabled] of [['↑','Omhoog',()=>move(i,i-1),i===0],['↓','Omlaag',()=>move(i,i+1),i===layout.tiles.length-1],['✕','Verwijderen',()=>{layout.tiles.splice(i,1);markDirty();renderTiles();renderResults();},false]]) { const b = node('button',label); b.title = title; b.setAttribute('aria-label',title); b.disabled = disabled; b.onclick = action; actions.append(b); } - li.append(node('span','⠿','grip'), content, actions); $('#tiles').append(li); + if (i === 6) $("#tiles").append(node("li", "Pagina 2", "page-break")); + const li = node("li", undefined, "tile"); + li.draggable = true; + li.ondragstart = (e) => { + if (e.target instanceof HTMLInputElement) { + e.preventDefault(); + return; + } + dragIndex = i; + e.dataTransfer.setData("text/plain", String(i)); + li.classList.add("dragging"); + }; + li.ondragend = () => { + dragIndex = -1; + li.classList.remove("dragging"); + }; + li.ondragover = (e) => { + if (dragIndex >= 0) e.preventDefault(); + }; + li.ondrop = (e) => { + e.preventDefault(); + if (dragIndex >= 0) move(dragIndex, i); + }; + const content = node("div"), + input = node("input"); + input.value = tile.name; + input.placeholder = + inventory.entities.find((e) => e.id === tile.entity)?.name || tile.entity; + input.maxLength = 60; + input.setAttribute("aria-label", `Naam voor tegel ${i + 1}`); + input.oninput = () => { + tile.name = input.value; + markDirty(); + }; + content.append(input, node("small", tile.entity)); + const actions = node("div", undefined, "tile-actions"); + for (const [label, title, action, disabled] of [ + ["↑", "Omhoog", () => move(i, i - 1), i === 0], + ["↓", "Omlaag", () => move(i, i + 1), i === layout.tiles.length - 1], + [ + "✕", + "Verwijderen", + () => { + layout.tiles.splice(i, 1); + markDirty(); + renderTiles(); + renderResults(); + }, + false, + ], + ]) { + const b = node("button", label); + b.title = title; + b.setAttribute("aria-label", title); + b.disabled = disabled; + b.onclick = action; + actions.append(b); + } + li.append(node("span", "⠿", "grip"), content, actions); + $("#tiles").append(li); }); } function renderResults() { if (!layout) return; - const query = $('#search').value.toLocaleLowerCase(); const chosen = new Set(layout.tiles.map(t => t.entity)); - const matches = inventory.entities.filter(e => (!filter || e.id.startsWith(filter+'.')) && `${e.name} ${e.id} ${e.device} ${e.area}`.toLocaleLowerCase().includes(query)); - $('#results').replaceChildren(); + const query = $("#search").value.toLocaleLowerCase(); + const chosen = new Set(layout.tiles.map((t) => t.entity)); + const matches = inventory.entities.filter( + (e) => + (!filter || e.id.startsWith(filter + ".")) && + `${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('span',entity.name), node('small',[entity.area,entity.device].filter(Boolean).join(' · ')), node('small',entity.id)); b.append(description,node('span',chosen.has(entity.id)?'✓':'+','plus')); b.disabled = chosen.has(entity.id) || layout.tiles.length >= 10; b.onclick = () => { layout.tiles.push({entity:entity.id,name:''}); markDirty();renderTiles();renderResults(); }; $('#results').append(b); + const b = node("button", undefined, "result"), + description = node("span"); + description.append( + node("span", entity.name), + node("small", [entity.area, entity.device].filter(Boolean).join(" · ")), + node("small", entity.id), + ); + b.append( + description, + node("span", chosen.has(entity.id) ? "✓" : "+", "plus"), + ); + b.disabled = chosen.has(entity.id) || layout.tiles.length >= 10; + b.onclick = () => { + layout.tiles.push({ entity: entity.id, name: "" }); + markDirty(); + renderTiles(); + renderResults(); + }; + $("#results").append(b); } - if (!matches.length) $('#results').append(node('p','Geen entiteiten gevonden. Probeer een andere naam.','hint')); - if (matches.length > 80) $('#results').append(node('p',`${matches.length} resultaten. Typ verder om te verfijnen.`,'hint')); + if (!matches.length) + $("#results").append( + node("p", "Geen entiteiten gevonden. Probeer een andere naam.", "hint"), + ); + if (matches.length > 80) + $("#results").append( + node( + "p", + `${matches.length} resultaten. Typ verder om te verfijnen.`, + "hint", + ), + ); } async function refresh() { - try { inventory = await (await api('inventory')).json(); $('#connection').textContent = inventory.connected ? '● Home Assistant verbonden' : 'Verbinding met Home Assistant herstellen…'; $('#connection').classList.toggle('online',inventory.connected); renderScreens(); if (!selected && inventory.screens.length) select(inventory.screens[0].id); } - catch { $('#connection').textContent = 'Beheerpagina niet bereikbaar · opnieuw proberen…'; } + try { + inventory = await (await api("inventory")).json(); + $("#connection").textContent = inventory.connected + ? "● Home Assistant verbonden" + : "Verbinding met Home Assistant herstellen…"; + $("#connection").classList.toggle("online", inventory.connected); + renderScreens(); + if (!selected && inventory.screens.length) select(inventory.screens[0].id); + } catch { + $("#connection").textContent = + "Beheerpagina niet bereikbaar · opnieuw proberen…"; + } } -$('#save').onclick = async () => { if (busy) 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 = 'Opgeslagen'; $('#save-detail').textContent = 'Synchronisatie volgt automatisch, ook na opnieuw verbinden.'; toast('Opgeslagen. Je scherm wordt bijgewerkt.'); await refresh(); } catch(e) { toast(e.message); } finally { busy = false; $('#save').disabled = false; } }; -$('#title').oninput = markDirty; $('#search').oninput = renderResults; $('#refresh').onclick = refresh; -for (const [value,label] of [['','Alles'],['light','Lampen'],['climate','Klimaat'],['scene','Scènes'],['vacuum','Vacuum'],['sensor','Sensoren']]) { const b = node('button',label,value===''?'active':''); b.onclick=()=>{filter=value;$('#filters').querySelectorAll('button').forEach(n=>n.classList.remove('active'));b.classList.add('active');renderResults();};$('#filters').append(b); } -$('#new-screen').onclick = $('#start').onclick = () => $('#installer').showModal(); $('#close-install').onclick = () => $('#installer').close(); -let generatedName = ''; -$('#install-form').onsubmit = async e => { e.preventDefault(); $('#download').disabled = true; try { const data = Object.fromEntries(new FormData(e.target)); const response = await api('install',{method:'POST',body:JSON.stringify(data)}); $('#yaml-output').value = await response.text(); generatedName = data.name; $('#yaml-result').hidden = false; $('#install-status').textContent='Je YAML is klaar. Kopieer hem naar ESPHome en bewaar het bestand met sleutels.'; } catch(e) { $('#install-status').textContent=e.message; } finally { $('#download').disabled=false; } }; -$('#copy-yaml').onclick = async () => { try { if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText($('#yaml-output').value); else { $('#yaml-output').select(); if (!document.execCommand('copy')) throw new Error(); } $('#install-status').textContent='Gekopieerd. Plak dit bij Edit in ESPHome Device Builder.'; } catch { $('#yaml-output').select(); $('#install-status').textContent='De tekst is geselecteerd. Kopieer met Ctrl+C of Command+C.'; } }; -$('#download-yaml').onclick = () => { const url = URL.createObjectURL(new Blob([$('#yaml-output').value],{type:'text/plain'})); const a=node('a');a.href=url;a.download=`${generatedName}.yaml`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);$('#install-status').textContent='Download gestart. Blokkeert je browser deze? Gebruik Kopieer YAML.'; }; -window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}}); -refresh(); setInterval(refresh, 10000); +$("#save").onclick = async () => { + if (busy) 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 = "Opgeslagen"; + $("#save-detail").textContent = + "Synchronisatie volgt automatisch, ook na opnieuw verbinden."; + toast("Opgeslagen. Je scherm wordt bijgewerkt."); + await refresh(); + } catch (e) { + toast(e.message); + } finally { + busy = false; + $("#save").disabled = false; + } +}; +$("#title").oninput = markDirty; +$("#search").oninput = renderResults; +$("#refresh").onclick = refresh; +for (const [value, label] of [ + ["", "Alles"], + ["light", "Lampen"], + ["climate", "Klimaat"], + ["scene", "Scènes"], + ["vacuum", "Vacuum"], + ["sensor", "Sensoren"], +]) { + const b = node("button", label, value === "" ? "active" : ""); + b.onclick = () => { + filter = value; + $("#filters") + .querySelectorAll("button") + .forEach((n) => n.classList.remove("active")); + b.classList.add("active"); + renderResults(); + }; + $("#filters").append(b); +} +$("#new-screen").onclick = $("#start").onclick = () => + $("#installer").showModal(); +$("#close-install").onclick = () => $("#installer").close(); +let generatedName = ""; +$("#install-form").onsubmit = async (e) => { + e.preventDefault(); + $("#download").disabled = true; + try { + const data = Object.fromEntries(new FormData(e.target)); + const response = await api("install", { + method: "POST", + body: JSON.stringify(data), + }); + $("#yaml-output").value = await response.text(); + generatedName = data.name; + $("#yaml-result").hidden = false; + $("#install-status").textContent = + "Je YAML is klaar. Kopieer hem naar ESPHome en bewaar het bestand met sleutels."; + } catch (e) { + $("#install-status").textContent = e.message; + } finally { + $("#download").disabled = false; + } +}; +$("#copy-yaml").onclick = async () => { + try { + if (navigator.clipboard && window.isSecureContext) + await navigator.clipboard.writeText($("#yaml-output").value); + else { + $("#yaml-output").select(); + if (!document.execCommand("copy")) throw new Error(); + } + $("#install-status").textContent = + "Gekopieerd. Plak dit bij Edit in ESPHome Device Builder."; + } catch { + $("#yaml-output").select(); + $("#install-status").textContent = + "De tekst is geselecteerd. Kopieer met Ctrl+C of Command+C."; + } +}; +$("#download-yaml").onclick = () => { + const url = URL.createObjectURL( + new Blob([$("#yaml-output").value], { type: "text/plain" }), + ); + const a = node("a"); + a.href = url; + a.download = `${generatedName}.yaml`; + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + $("#install-status").textContent = + "Download gestart. Blokkeert je browser deze? Gebruik Kopieer YAML."; +}; +window.addEventListener("beforeunload", (e) => { + if (dirty) { + e.preventDefault(); + e.returnValue = ""; + } +}); +refresh(); +setInterval(refresh, 10000); diff --git a/screen_manager/app/static/index.html b/screen_manager/app/static/index.html index f13a838..523113d 100644 --- a/screen_manager/app/static/index.html +++ b/screen_manager/app/static/index.html @@ -1,21 +1,227 @@ -ESP Screens - -
▦
ESP ScreensJe huis. Jouw bediening.
-
-

HOME ASSISTANT

Een plek voor je favorieten.

Kies wat je wilt bedienen. Je scherm verandert met je mee.

Verbinden…
-
- -
▦

Begin met een scherm

Sluit een CYD of Guition aan. We maken de installatie-YAML voor je en helpen je met de eerste flash.

-
-
Hoe werkt het dagelijks?

Zoek een entiteit, voeg deze toe en klik op Opslaan & naar scherm. De app houdt de status bij. Voor een kleurenlamp: houd de tegel ingedrukt voor kleur, temperatuur en helderheid. Climate, vacuum en cover openen hun kaart direct. Sensoren tonen alleen hun waarde.

Alleen nieuwe firmware vraagt om een installatie via ESPHome Device Builder. Je tegels blijven in deze app bewaard. Een offline scherm krijgt de wijzigingen zodra het terug is.

-
-

NIEUW SCHERM

Van USB naar aan de muur.

Kies je scherm. Elk bestand krijgt eigen sleutels voor een veilige koppeling en draadloze updates.

Welk scherm heb je?

  1. Open ESPHome Device Builder. Voeg een nieuw apparaat toe, sla de eerste installatie over en vervang de inhoud via Edit door het gedownloade bestand.
  2. Vul wifi eenmalig in bij Secrets. Gebruik wifi_ssid en wifi_password.
  3. Sluit USB aan op je Home Assistant Raspberry. Klik Install → Plug into the computer running ESPHome Device Builder en kies de USB-poort.
  4. Koppel in Instellingen → Apparaten & diensten → ESPHome. Gebruik de api.encryption.key uit je bestand en sta Home Assistant-acties toe bij Configureren.
  5. Open deze pagina en kies je tegels. CYD toont bij de eerste start een kalibratie: tik ieder kruisje drie keer. Guition hoeft niet gekalibreerd.

Bewaar je YAML met sleutels. Gebruik later Install → Wirelessly voor firmware-updates. Tegels wijzigen doe je hier.

- - + + + + + ESP Screens + + + + +
+
+ ▦ +
+ ESP ScreensJe huis. Jouw bediening. +
+
+ +
+
+
+
+

HOME ASSISTANT

+

Een plek voor je favorieten.

+

Kies wat je wilt bedienen. Je scherm verandert met je mee.

+
+ Verbinden… +
+
+ +
+ ▦ +

Begin met een scherm

+

+ Sluit een CYD of Guition aan. We maken de installatie-YAML voor je + en helpen je met de eerste flash. +

+ +
+ +
+ +
+ Hoe werkt het dagelijks? +

+ Zoek een entiteit, voeg deze toe en klik op + Opslaan & naar scherm. De app houdt de status bij. Voor een + kleurenlamp: houd de tegel ingedrukt voor kleur, temperatuur en + helderheid. Climate, vacuum en cover openen hun kaart direct. Sensoren + tonen alleen hun waarde. +

+

+ Alleen nieuwe firmware vraagt om een installatie via ESPHome Device + Builder. Je tegels blijven in deze app bewaard. Een offline scherm + krijgt de wijzigingen zodra het terug is. +

+
+
+ +
+
+
+

NIEUW SCHERM

+

Van USB naar aan de muur.

+
+ +
+

+ Kies je scherm. Elk bestand krijgt eigen sleutels voor een veilige + koppeling en draadloze updates. +

+
+ Welk scherm heb je? + +
+ +

+ +
    +
  1. + Open ESPHome Device Builder. Voeg een nieuw apparaat toe, sla + de eerste installatie over en vervang de inhoud via Edit door het + gedownloade bestand. +
  2. +
  3. + Vul wifi eenmalig in bij Secrets. Gebruik + wifi_ssid en wifi_password. +
  4. +
  5. + Sluit USB aan op je Home Assistant Raspberry. Klik Install → + Plug into the computer running ESPHome Device Builder en kies de + USB-poort. +
  6. +
  7. + Koppel in Instellingen → Apparaten & diensten → ESPHome. + Gebruik de api.encryption.key uit je bestand en sta + Home Assistant-acties toe bij Configureren. +
  8. +
  9. + Open deze pagina en kies je tegels. CYD toont bij de eerste + start een kalibratie: tik ieder kruisje drie keer. Guition hoeft + niet gekalibreerd. +
  10. +
+

+ Bewaar je YAML met sleutels. Gebruik later Install → Wirelessly voor + firmware-updates. Tegels wijzigen doe je hier. +

+
+
+ + + diff --git a/screen_manager/app/static/style.css b/screen_manager/app/static/style.css index 95c3f73..6d35171 100644 --- a/screen_manager/app/static/style.css +++ b/screen_manager/app/static/style.css @@ -1,5 +1,565 @@ -:root{color-scheme:light;--bg:#f5f6f8;--ink:#202c3c;--muted:#697689;--line:#e1e6ed;--accent:#245cde;--soft:#edf3ff;font:15px/1.5 system-ui,-apple-system,sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}button,input{font:inherit}button{border:0;border-radius:11px;padding:11px 18px;background:var(--accent);color:white;font-weight:600;cursor:pointer}button:hover{filter:brightness(.95)}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid #83b5ff;outline-offset:3px}input{border:1px solid #cdd5df;border-radius:10px;padding:11px 12px;width:100%;background:white;color:var(--ink)}label{display:block;font-weight:600}label input{margin-top:7px}small,.hint{display:block;color:var(--muted);font-size:13px;font-weight:400}header{display:flex;align-items:center;justify-content:space-between;padding:21px 4vw;background:white;border-bottom:1px solid var(--line);gap:20px}.brand{display:flex;align-items:center;gap:12px}.brand strong{font-size:19px}.mark{width:44px;height:44px;background:var(--ink);color:white;border-radius:12px;display:grid;place-items:center;font-size:30px}main{max-width:1440px;margin:auto;padding:38px 4vw}.intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:30px}h1{font-size:clamp(25px,3vw,36px);letter-spacing:-1.1px;margin:4px 0 8px}h2{font-size:21px;letter-spacing:-.4px;margin:0 0 14px}h3{font-size:16px;margin:0}p{margin:8px 0 16px}.intro p:not(.eyebrow){color:var(--muted)}.eyebrow{font-size:11px;font-weight:750;letter-spacing:1.6px;color:var(--muted);margin:0 0 7px}.badge{display:inline-block;border:1px solid var(--line);border-radius:30px;padding:6px 11px;font-size:12px;white-space:normal;background:white;color:var(--muted)}.badge.online{background:#e9f6ef;color:#21704b;border-color:#d6eede}.workspace{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px}aside{padding-top:10px}aside h2{font-size:14px}#screens{display:grid;gap:8px;margin-bottom:18px}.screen{text-align:left;background:transparent;color:var(--ink);border:1px solid transparent;padding:14px}.screen.selected{background:white;border-color:var(--line);box-shadow:0 3px 10px #202c3c05}.screen small{margin-top:3px}.quiet{background:transparent;color:var(--muted);padding:7px}#editor,.empty{background:white;border:1px solid var(--line);border-radius:18px;padding:27px;min-width:0;box-shadow:0 5px 20px #202c3c03}.editor-head{display:flex;justify-content:space-between;gap:12px;align-items:start;margin-bottom:15px}.editor-head .badge{max-width:240px}.columns{display:grid;grid-template-columns:1fr 1fr;gap:25px;margin-top:30px}.section-title{display:flex;justify-content:space-between}.section-title span{font-size:12px;color:var(--muted)}#tiles{padding:0;list-style:none;margin:18px 0}.tile{border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:10px;background:#fcfcfd;display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:8px;align-items:center}.tile.dragging{opacity:.35}.grip{color:#9aa7b8;cursor:grab;font-size:19px}.tile input{font-size:14px;padding:5px 6px;margin:0;background:transparent;border-color:transparent}.tile input:focus{border-color:#aac4fa;background:white}.tile small{font-size:11px;overflow-wrap:anywhere;padding-left:6px}.tile-actions{display:flex;flex-direction:column}.tile-actions button{padding:1px 6px;font-size:12px;line-height:1.6;background:transparent;color:var(--muted)}.page-break{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin:18px 0 9px}.picker{background:#f7f9fc;padding:18px;border-radius:13px;align-self:start}.picker label{font-size:14px}.filters{display:flex;gap:5px;flex-wrap:wrap;margin:13px 0}.filters button{font-size:11px;padding:5px 9px;background:white;color:var(--muted);border:1px solid var(--line);border-radius:20px}.filters button.active{background:var(--soft);color:var(--accent);border-color:#c9dafa}#results{max-height:450px;overflow:auto}.result{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;text-align:left;background:transparent;color:var(--ink);padding:11px 5px;border-bottom:1px solid var(--line);border-radius:0;font-size:13px;font-weight:500}.result small{font-size:11px;overflow-wrap:anywhere}.result .plus{color:var(--accent);font-size:22px}.savebar{margin-top:25px;padding-top:20px;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:15px}.savebar strong{font-size:13px}.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:400px;text-align:center}.empty p{max-width:400px;color:var(--muted)}.empty-icon{font-size:60px;color:#9cafd0}.help{margin:26px 0 0 238px;color:var(--muted);font-size:13px}.help summary{cursor:pointer;font-weight:600}.help p{margin-top:15px}dialog{border:1px solid var(--line);border-radius:20px;max-width:620px;width:calc(100% - 28px);padding:28px;color:var(--ink);max-height:90vh}dialog::backdrop{background:#17223280;backdrop-filter:blur(3px)}.dialog-head{display:flex;align-items:start;justify-content:space-between;gap:15px}dialog p{color:var(--muted);font-size:14px}fieldset{border:0;padding:0;margin:23px 0;display:grid;grid-template-columns:1fr 1fr;gap:10px}legend{font-weight:600;margin-bottom:9px}.board{border:1px solid var(--line);border-radius:12px;padding:13px;display:flex;align-items:center;gap:9px;font-size:13px}.board:has(input:checked){background:var(--soft);border-color:#9bb9fa}.board input{width:auto;margin:0}.board small{font-size:11px}dialog label{margin-bottom:15px;font-size:14px}.steps{padding-left:20px;font-size:13px;color:var(--muted)}.steps li{padding-left:4px;margin-bottom:12px}.steps b{color:var(--ink)}#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:14px 22px;background:var(--ink);color:white;border-radius:13px;box-shadow:0 5px 30px #0003;max-width:90vw;z-index:10}[hidden]{display:none!important}@media(max-width:1000px){.workspace{grid-template-columns:160px minmax(0,1fr);gap:18px}.columns{grid-template-columns:1fr}.help{margin-left:178px}.editor-head{display:block}.editor-head .badge{margin-bottom:10px}}@media(max-width:620px){main{padding:25px 16px}.workspace{display:block}aside{margin-bottom:18px}#screens{display:flex;overflow-x:auto}.screen{min-width:160px}aside>.hint{display:none}.intro{display:block}.intro>.badge{margin-top:8px}#editor{padding:18px}.help{margin-left:0}.savebar{align-items:stretch;flex-direction:column}header{padding:15px}.brand small{display:none}header button{font-size:12px}fieldset{grid-template-columns:1fr}} +:root { + color-scheme: light; + --bg: #f5f6f8; + --ink: #202c3c; + --muted: #697689; + --line: #e1e6ed; + --accent: #245cde; + --soft: #edf3ff; + font: + 15px/1.5 system-ui, + -apple-system, + sans-serif; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--bg); + color: var(--ink); +} +button, +input { + font: inherit; +} +button { + border: 0; + border-radius: 11px; + padding: 11px 18px; + background: var(--accent); + color: white; + font-weight: 600; + cursor: pointer; +} +button:hover { + filter: brightness(0.95); +} +button:disabled { + opacity: 0.5; + cursor: default; +} +button:focus-visible, +input:focus-visible, +summary:focus-visible { + outline: 3px solid #83b5ff; + outline-offset: 3px; +} +input { + border: 1px solid #cdd5df; + border-radius: 10px; + padding: 11px 12px; + width: 100%; + background: white; + color: var(--ink); +} +label { + display: block; + font-weight: 600; +} +label input { + margin-top: 7px; +} +small, +.hint { + display: block; + color: var(--muted); + font-size: 13px; + font-weight: 400; +} +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 21px 4vw; + background: white; + border-bottom: 1px solid var(--line); + gap: 20px; +} +.brand { + display: flex; + align-items: center; + gap: 12px; +} +.brand strong { + font-size: 19px; +} +.mark { + width: 44px; + height: 44px; + background: var(--ink); + color: white; + border-radius: 12px; + display: grid; + place-items: center; + font-size: 30px; +} +main { + max-width: 1440px; + margin: auto; + padding: 38px 4vw; +} +.intro { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + margin-bottom: 30px; +} +h1 { + font-size: clamp(25px, 3vw, 36px); + letter-spacing: -1.1px; + margin: 4px 0 8px; +} +h2 { + font-size: 21px; + letter-spacing: -0.4px; + margin: 0 0 14px; +} +h3 { + font-size: 16px; + margin: 0; +} +p { + margin: 8px 0 16px; +} +.intro p:not(.eyebrow) { + color: var(--muted); +} +.eyebrow { + font-size: 11px; + font-weight: 750; + letter-spacing: 1.6px; + color: var(--muted); + margin: 0 0 7px; +} +.badge { + display: inline-block; + border: 1px solid var(--line); + border-radius: 30px; + padding: 6px 11px; + font-size: 12px; + white-space: normal; + background: white; + color: var(--muted); +} +.badge.online { + background: #e9f6ef; + color: #21704b; + border-color: #d6eede; +} +.workspace { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 28px; +} +aside { + padding-top: 10px; +} +aside h2 { + font-size: 14px; +} +#screens { + display: grid; + gap: 8px; + margin-bottom: 18px; +} +.screen { + text-align: left; + background: transparent; + color: var(--ink); + border: 1px solid transparent; + padding: 14px; +} +.screen.selected { + background: white; + border-color: var(--line); + box-shadow: 0 3px 10px #202c3c05; +} +.screen small { + margin-top: 3px; +} +.quiet { + background: transparent; + color: var(--muted); + padding: 7px; +} +#editor, +.empty { + background: white; + border: 1px solid var(--line); + border-radius: 18px; + padding: 27px; + min-width: 0; + box-shadow: 0 5px 20px #202c3c03; +} +.editor-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; + margin-bottom: 15px; +} +.editor-head .badge { + max-width: 240px; +} +.columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 25px; + margin-top: 30px; +} +.section-title { + display: flex; + justify-content: space-between; +} +.section-title span { + font-size: 12px; + color: var(--muted); +} +#tiles { + padding: 0; + list-style: none; + margin: 18px 0; +} +.tile { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; + margin-bottom: 10px; + background: #fcfcfd; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} +.tile.dragging { + opacity: 0.35; +} +.grip { + color: #9aa7b8; + cursor: grab; + font-size: 19px; +} +.tile input { + font-size: 14px; + padding: 5px 6px; + margin: 0; + background: transparent; + border-color: transparent; +} +.tile input:focus { + border-color: #aac4fa; + background: white; +} +.tile small { + font-size: 11px; + overflow-wrap: anywhere; + padding-left: 6px; +} +.tile-actions { + display: flex; + flex-direction: column; +} +.tile-actions button { + padding: 1px 6px; + font-size: 12px; + line-height: 1.6; + background: transparent; + color: var(--muted); +} +.page-break { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1px; + margin: 18px 0 9px; +} +.picker { + background: #f7f9fc; + padding: 18px; + border-radius: 13px; + align-self: start; +} +.picker label { + font-size: 14px; +} +.filters { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin: 13px 0; +} +.filters button { + font-size: 11px; + padding: 5px 9px; + background: white; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 20px; +} +.filters button.active { + background: var(--soft); + color: var(--accent); + border-color: #c9dafa; +} +#results { + max-height: 450px; + overflow: auto; +} +.result { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + text-align: left; + background: transparent; + color: var(--ink); + padding: 11px 5px; + border-bottom: 1px solid var(--line); + border-radius: 0; + font-size: 13px; + font-weight: 500; +} +.result small { + font-size: 11px; + overflow-wrap: anywhere; +} +.result .plus { + color: var(--accent); + font-size: 22px; +} +.savebar { + margin-top: 25px; + padding-top: 20px; + border-top: 1px solid var(--line); + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; +} +.savebar strong { + font-size: 13px; +} +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + text-align: center; +} +.empty p { + max-width: 400px; + color: var(--muted); +} +.empty-icon { + font-size: 60px; + color: #9cafd0; +} +.help { + margin: 26px 0 0 238px; + color: var(--muted); + font-size: 13px; +} +.help summary { + cursor: pointer; + font-weight: 600; +} +.help p { + margin-top: 15px; +} +dialog { + border: 1px solid var(--line); + border-radius: 20px; + max-width: 620px; + width: calc(100% - 28px); + padding: 28px; + color: var(--ink); + max-height: 90vh; +} +dialog::backdrop { + background: #17223280; + backdrop-filter: blur(3px); +} +.dialog-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 15px; +} +dialog p { + color: var(--muted); + font-size: 14px; +} +fieldset { + border: 0; + padding: 0; + margin: 23px 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +legend { + font-weight: 600; + margin-bottom: 9px; +} +.board { + border: 1px solid var(--line); + border-radius: 12px; + padding: 13px; + display: flex; + align-items: center; + gap: 9px; + font-size: 13px; +} +.board:has(input:checked) { + background: var(--soft); + border-color: #9bb9fa; +} +.board input { + width: auto; + margin: 0; +} +.board small { + font-size: 11px; +} +dialog label { + margin-bottom: 15px; + font-size: 14px; +} +.steps { + padding-left: 20px; + font-size: 13px; + color: var(--muted); +} +.steps li { + padding-left: 4px; + margin-bottom: 12px; +} +.steps b { + color: var(--ink); +} +#toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 14px 22px; + background: var(--ink); + color: white; + border-radius: 13px; + box-shadow: 0 5px 30px #0003; + max-width: 90vw; + z-index: 10; +} +[hidden] { + display: none !important; +} +@media (max-width: 1000px) { + .workspace { + grid-template-columns: 160px minmax(0, 1fr); + gap: 18px; + } + .columns { + grid-template-columns: 1fr; + } + .help { + margin-left: 178px; + } + .editor-head { + display: block; + } + .editor-head .badge { + margin-bottom: 10px; + } +} +@media (max-width: 620px) { + main { + padding: 25px 16px; + } + .workspace { + display: block; + } + aside { + margin-bottom: 18px; + } + #screens { + display: flex; + overflow-x: auto; + } + .screen { + min-width: 160px; + } + aside > .hint { + display: none; + } + .intro { + display: block; + } + .intro > .badge { + margin-top: 8px; + } + #editor { + padding: 18px; + } + .help { + margin-left: 0; + } + .savebar { + align-items: stretch; + flex-direction: column; + } + header { + padding: 15px; + } + .brand small { + display: none; + } + header button { + font-size: 12px; + } + fieldset { + grid-template-columns: 1fr; + } +} -textarea{font:12px/1.5 ui-monospace,monospace;width:100%;resize:vertical;border:1px solid var(--line);border-radius:10px;padding:12px;margin-top:8px;background:#f7f9fc;color:var(--ink)}.yaml-actions{display:flex;gap:10px;margin-bottom:20px} +textarea { + font: + 12px/1.5 ui-monospace, + monospace; + width: 100%; + resize: vertical; + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px; + margin-top: 8px; + background: #f7f9fc; + color: var(--ink); +} +.yaml-actions { + display: flex; + gap: 10px; + margin-bottom: 20px; +} -.firmware-link{margin:26px 0 0 238px;font-size:13px}.firmware-link a{color:var(--accent);text-decoration:none;font-weight:600}@media(max-width:1000px){.firmware-link{margin-left:178px}}@media(max-width:620px){.firmware-link{margin-left:0}} +.firmware-link { + margin: 26px 0 0 238px; + font-size: 13px; +} +.firmware-link a { + color: var(--accent); + text-decoration: none; + font-weight: 600; +} +@media (max-width: 1000px) { + .firmware-link { + margin-left: 178px; + } +} +@media (max-width: 620px) { + .firmware-link { + margin-left: 0; + } +} diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index d57b407..b9dc880 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.1.0" +version: "0.1.1" slug: esp_screen_manager description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen. url: https://github.com/MaxGramser/homeassistant_espscreen