One-window install: profile, wifi, USB flash and pairing hints (0.2.27)
"Nieuw scherm" used to span two dialogs plus a copy/download of the installation YAML. It is now one dialog: board, name (node name derived, editable), wifi only when secrets.yaml lacks it, the detected USB port, and Installeren. The add-on writes the profile with unique API/OTA keys, merges just the missing wifi lines into secrets.yaml (comments and other secrets untouched, result re-parsed before writing), then builds and flashes with the live ESPHome log and stage in the same dialog; a failed build offers a retry without a second profile. The YAML download endpoint is gone: the profile already lives in the ESPHome folder. Pairing happens in Home Assistant itself, so the done view and a sidebar card per not-yet-paired profile say so, with a button to Apparaten & diensten, the API key to copy and the reminder to allow HA actions. One profile per screen; the dialog says that too. Tests: wifi merge cases, install pre-checks, pending list, profile metadata; the date-anchored hourly forecast test now follows the clock. Firmware 0.2.22 stays current. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import shutil
|
||||
import signal
|
||||
import time
|
||||
import yaml
|
||||
from core import installation_yaml
|
||||
from core import REPO, installation_yaml
|
||||
|
||||
# libyaml parses a 300 KB profile roughly ten times faster than the pure-Python loader.
|
||||
class LenientLoader(yaml.CSafeLoader if getattr(yaml, '__with_libyaml__', False) else yaml.SafeLoader):
|
||||
@@ -28,7 +28,14 @@ def profile_meta(text):
|
||||
if isinstance(value, str) and value.startswith('${') and value.endswith('}'):
|
||||
value = substitutions.get(value[2:-1])
|
||||
return value if isinstance(value, str) else None
|
||||
return {'node': resolve(block.get('name')), 'friendly': resolve(block.get('friendly_name'))}
|
||||
# 'screen': the profile pulls this project's board package, so it is one of ours (not any ESPHome device).
|
||||
packages = data.get('packages') if isinstance(data.get('packages'), dict) else {}
|
||||
ours = any(isinstance(entry, dict) and REPO in str(entry.get('url', '')) for entry in packages.values())
|
||||
api = data.get('api') if isinstance(data.get('api'), dict) else {}
|
||||
encryption = api.get('encryption') if isinstance(api.get('encryption'), dict) else {}
|
||||
key = encryption.get('key')
|
||||
return {'node': resolve(block.get('name')), 'friendly': resolve(block.get('friendly_name')),
|
||||
'screen': ours, 'api_key': key if isinstance(key, str) else None}
|
||||
|
||||
class Firmware:
|
||||
def __init__(self, root, data):
|
||||
@@ -37,6 +44,7 @@ class Firmware:
|
||||
self.task = None
|
||||
self.logs = deque(maxlen=300)
|
||||
self.process = None
|
||||
self.installed = set() # profiles this process flashed successfully; the page nudges pairing for them
|
||||
self._names = {} # file -> (stat signature, meta or None); parsed only when the file changes
|
||||
|
||||
def profiles(self):
|
||||
@@ -101,28 +109,74 @@ class Firmware:
|
||||
return {'available': bool(shutil.which('esphome')), 'profiles': self.profiles(),
|
||||
'ports': self.ports(), 'job': self.job, 'logs': list(self.logs), 'wifi': self.wifi_status()}
|
||||
|
||||
def store_wifi(self, data):
|
||||
"""Put wifi_ssid/wifi_password in secrets.yaml when they are missing. Existing values and
|
||||
every other secret stay as they are; the browser never sees stored values."""
|
||||
wifi = self.wifi_status()
|
||||
if wifi['state'] == 'ready':
|
||||
return
|
||||
if wifi['state'] == 'invalid':
|
||||
raise ValueError('secrets.yaml in de ESPHome-map is geen geldige YAML. Herstel het bestand eerst; het wordt niet overschreven.')
|
||||
values = {}
|
||||
for key in wifi['missing']:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or (key == 'wifi_ssid' and not value.strip()):
|
||||
raise ValueError('Vul de wifi-naam en het wachtwoord in; ze worden in ESPHome secrets.yaml bewaard.')
|
||||
values[key] = value
|
||||
path = self.root / 'secrets.yaml'
|
||||
if wifi['state'] == 'new':
|
||||
with path.open('x') as f:
|
||||
os.chmod(path, 0o600)
|
||||
yaml.safe_dump(values, f, width=4096)
|
||||
return
|
||||
# An existing file with missing keys: replace only that key's own line, or append it.
|
||||
text = path.read_text()
|
||||
for key, value in values.items():
|
||||
line = yaml.safe_dump({key: value}, width=4096).strip()
|
||||
pattern = re.compile(rf'^{key}\s*:.*$', re.M)
|
||||
if pattern.search(text):
|
||||
text = pattern.sub(lambda match: line, text, count=1)
|
||||
else:
|
||||
text = text + ('' if not text or text.endswith('\n') else '\n') + line + '\n'
|
||||
check = yaml.safe_load(text)
|
||||
if not isinstance(check, dict) or any(check.get(key) != value for key, value in values.items()):
|
||||
raise ValueError('Wifi kon niet in secrets.yaml worden gezet. Vul wifi_ssid en wifi_password daar zelf in.')
|
||||
path.write_text(text)
|
||||
|
||||
def create(self, data):
|
||||
"""Write the device profile (unique API/OTA keys) and any missing wifi secrets."""
|
||||
content = installation_yaml(data)
|
||||
self.root.mkdir(parents=True,exist_ok=True)
|
||||
# Existing wifi secrets remain untouched. New owners can supply them once.
|
||||
secret_path = self.root / 'secrets.yaml'
|
||||
wifi = self.wifi_status()
|
||||
if wifi['state'] in ('missing', 'invalid'):
|
||||
raise ValueError('Controleer wifi_ssid en wifi_password in ESPHome secrets.yaml. Bestaande secrets blijven behouden.')
|
||||
if wifi['state'] == 'new':
|
||||
ssid, password = data.get('wifi_ssid'), data.get('wifi_password')
|
||||
if not isinstance(ssid,str) or not ssid.strip() or not isinstance(password,str):
|
||||
raise ValueError('Vul wifi in voor deze eerste installatie.')
|
||||
with secret_path.open('x') as f:
|
||||
os.chmod(secret_path,0o600)
|
||||
yaml.safe_dump({'wifi_ssid':ssid,'wifi_password':password}, f)
|
||||
profile = self.root / (data['name']+'.yaml')
|
||||
if profile.exists() or profile.is_symlink():
|
||||
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.')
|
||||
self.store_wifi(data)
|
||||
try:
|
||||
with profile.open('x') as f:
|
||||
os.chmod(profile,0o600);f.write(content)
|
||||
except FileExistsError:
|
||||
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.')
|
||||
return {'file':profile.name, 'yaml':content}
|
||||
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 install(self, data):
|
||||
"""Profile plus, when a USB port is chosen, the build and flash in one go.
|
||||
|
||||
The port and the job slot are checked before anything is written, so a refused
|
||||
install leaves no half-made profile behind."""
|
||||
target = data.get('target') or ''
|
||||
if target:
|
||||
if not isinstance(target, str) or target not in self.ports():
|
||||
raise ValueError('Kies de aangesloten USB-poort uit de lijst.')
|
||||
if self.task and not self.task.done():
|
||||
raise ValueError('Er loopt al een build of installatie. Wacht tot die klaar is.')
|
||||
if not shutil.which('esphome'):
|
||||
raise ValueError('ESPHome CLI ontbreekt in deze installatie.')
|
||||
result = self.create(data)
|
||||
if target:
|
||||
result['job'] = self.start({'file': result['file'], 'action': 'install', 'target': target})
|
||||
return result
|
||||
|
||||
def redact(self, text):
|
||||
text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
|
||||
@@ -171,6 +225,7 @@ class Firmware:
|
||||
for stage in stages:
|
||||
cmd=['esphome']+(['--quiet'] if stage=='config' else [])+[stage,str(profile)]
|
||||
if stage=='upload': cmd += ['--device',target]
|
||||
self.job['stage']=stage
|
||||
self.logs.append('ESPHome: '+stage)
|
||||
self.process=await asyncio.create_subprocess_exec(*cmd,cwd=self.root,env=env,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.STDOUT,limit=1024*1024,start_new_session=True)
|
||||
async with asyncio.timeout(7200):
|
||||
@@ -179,6 +234,7 @@ class Firmware:
|
||||
code=await self.process.wait()
|
||||
if code: raise RuntimeError('ESPHome '+stage+' mislukt; bekijk het log.')
|
||||
self.job['state']='success'; self.logs.append('Geslaagd: '+action)
|
||||
if action=='install': self.installed.add(profile.name)
|
||||
except asyncio.CancelledError:
|
||||
self.job['state']='interrupted'
|
||||
raise
|
||||
|
||||
@@ -14,7 +14,7 @@ import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BUILTIN, TILE_BACKGROUNDS, controls_catalogue, discover, extras, installation_yaml, min_firmware, packets, state_message, validate_layout, validate_settings
|
||||
from core import BUILTIN, TILE_BACKGROUNDS, controls_catalogue, discover, extras, min_firmware, packets, state_message, validate_layout, validate_settings
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
@@ -291,6 +291,19 @@ class Manager:
|
||||
for listener in self.listeners:
|
||||
listener.set()
|
||||
|
||||
def pending_profiles(self, screens, profiles):
|
||||
"""ESP Screens profiles without a paired screen: flashed but not yet added in Home Assistant, or not flashed yet.
|
||||
|
||||
Pairing happens in Home Assistant itself, outside this page; the sidebar shows these so nobody wonders
|
||||
where the freshly flashed screen went."""
|
||||
nodes = {s.get('node') for s in screens}
|
||||
devices = {s.get('device') for s in screens}
|
||||
installed = getattr(self.firmware, 'installed', set())
|
||||
return [{'file': file, 'node': meta['node'], 'friendly': meta.get('friendly') or meta['node'] or file,
|
||||
'installed': file in installed, 'api_key': meta.get('api_key')}
|
||||
for file, meta in profiles.items()
|
||||
if meta.get('screen') and meta.get('node') not in nodes and (meta.get('friendly') or None) not in devices]
|
||||
|
||||
def watched_entities(self):
|
||||
"""Entities whose state changes matter: tiles on any layout plus the screens' own diagnostics."""
|
||||
watched = {tile['entity'] for layout in self.layouts.values() for tile in layout['tiles']}
|
||||
@@ -431,6 +444,7 @@ def create_app(manager, development=False):
|
||||
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels')
|
||||
screen['update'] = manager.updates.state_for(screen, profiles)
|
||||
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
|
||||
'pending': manager.pending_profiles(screens, profiles),
|
||||
'updates': manager.updates.summary(screens, profiles)}, entities
|
||||
async def inventory(request):
|
||||
payload, entities = light_payload()
|
||||
@@ -478,10 +492,6 @@ def create_app(manager, development=False):
|
||||
async def update_settings(request):
|
||||
manager.updates.set_auto((await request.json()).get('auto'))
|
||||
return web.json_response(manager.updates.summary())
|
||||
async def download(request):
|
||||
data = await request.json()
|
||||
content = installation_yaml(data)
|
||||
return web.Response(text=content, content_type='text/yaml', headers={'Content-Disposition': f'attachment; filename="{data["name"]}.yaml"'})
|
||||
async def inspector(request):
|
||||
inbox=request.match_info['inbox']
|
||||
if inbox not in {s['id'] for s in manager.inventory()[0]}: raise ValueError('Onbekend scherm.')
|
||||
@@ -493,7 +503,9 @@ def create_app(manager, development=False):
|
||||
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
|
||||
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_create(request): return web.json_response(manager.firmware.create(await request.json()))
|
||||
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()))
|
||||
async def shutdown(app):
|
||||
for task in (manager.updates.task, manager.firmware.task):
|
||||
if task and not task.done():
|
||||
@@ -511,7 +523,6 @@ def create_app(manager, development=False):
|
||||
app.router.add_get('/api/inventory', inventory)
|
||||
app.router.add_get('/api/events', events)
|
||||
app.router.add_put('/api/screens/{inbox}', save)
|
||||
app.router.add_post('/api/install', download)
|
||||
app.router.add_static('/static/', static)
|
||||
return app
|
||||
|
||||
|
||||
@@ -260,7 +260,62 @@ function renderUpdate(screen) {
|
||||
} else return null;
|
||||
return row;
|
||||
}
|
||||
function openIntegrations() {
|
||||
// Pairing happens in Home Assistant itself. This page lives in HA's ingress iframe,
|
||||
// so send the top window to Apparaten & diensten (same origin); elsewhere open a tab.
|
||||
const path = "/config/integrations/dashboard";
|
||||
try {
|
||||
window.top.location.assign(path);
|
||||
} catch {
|
||||
window.open(path, "_blank");
|
||||
}
|
||||
}
|
||||
async function copyText(text, element) {
|
||||
try {
|
||||
if (!navigator.clipboard || !window.isSecureContext) throw new Error();
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("API-sleutel gekopieerd.");
|
||||
} catch {
|
||||
if (element) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
toast(document.execCommand("copy") ? "API-sleutel gekopieerd." : "De sleutel is geselecteerd. Kopieer met Ctrl+C of Command+C.");
|
||||
}
|
||||
}
|
||||
// Profiles that Home Assistant does not list yet: the freshly flashed screen is not lost, it
|
||||
// still has to be added under Apparaten & diensten, outside this page.
|
||||
function renderPending() {
|
||||
const box = $("#pending");
|
||||
box.replaceChildren();
|
||||
for (const p of inventory.pending || []) {
|
||||
const card = node("div", undefined, "pending");
|
||||
card.append(
|
||||
node("strong", p.friendly),
|
||||
node("small", p.installed
|
||||
? "Geïnstalleerd, maar nog niet in Home Assistant. Dat doe je buiten ESP Screens: voeg het ontdekte ESPHome-apparaat toe onder Instellingen → Apparaten & diensten, plak daar de API-sleutel en zet bij Configureren “Allow the device to perform Home Assistant actions” aan."
|
||||
: `Nog niet in Home Assistant. Al geflasht? Voeg het ESPHome-apparaat toe onder Instellingen → Apparaten & diensten en sta daarna bij Configureren de Home Assistant-acties toe. Nog niet geflasht? Firmware & USB → ${p.file}.`),
|
||||
);
|
||||
const actions = node("div", undefined, "pending-actions");
|
||||
const go = node("button", "Open Apparaten & diensten", "mini");
|
||||
go.type = "button";
|
||||
go.onclick = openIntegrations;
|
||||
actions.append(go);
|
||||
if (p.api_key) {
|
||||
const copy = node("button", "Kopieer API-sleutel", "mini quiet");
|
||||
copy.type = "button";
|
||||
copy.onclick = () => copyText(p.api_key);
|
||||
actions.append(copy);
|
||||
}
|
||||
card.append(actions);
|
||||
box.append(card);
|
||||
}
|
||||
}
|
||||
function renderScreens() {
|
||||
renderPending();
|
||||
// Keep an open address form alive across the periodic refresh.
|
||||
if ($("#screens .screen-host")) {
|
||||
renderUpdates();
|
||||
@@ -976,85 +1031,250 @@ for (const [value, label] of [
|
||||
};
|
||||
$("#filters").append(b);
|
||||
}
|
||||
async function checkWifiSecrets() {
|
||||
const fields = $("#wifi-fields"), status = $("#wifi-status");
|
||||
fields.hidden = true;
|
||||
fields.disabled = true;
|
||||
$("#create-profile").disabled = true;
|
||||
status.textContent = "ESPHome wifi-instellingen controleren…";
|
||||
// ----- Nieuw scherm: profiel, wifi en de eerste flash in één venster -----
|
||||
const installer = {
|
||||
poll: null, view: "setup", file: null, friendly: "", board: "cyd", target: "",
|
||||
apiKey: null, nodeEdited: false, ports: null, jobState: null,
|
||||
};
|
||||
// ESPHome's node-name rule: lowercase ASCII, digits and dashes, starting with a letter.
|
||||
function slug(text) {
|
||||
const clean = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 30).replace(/-+$/, "");
|
||||
return clean || "scherm";
|
||||
}
|
||||
function portLabel(port) {
|
||||
const id = port.replace(/^\/dev\/serial\/by-id\/usb-/, "").replace(/-if\d+(-port\d+)?$/, "").replace(/_/g, " ");
|
||||
return `USB · ${id === port ? port.replace(/^\/dev\//, "") : id}`;
|
||||
}
|
||||
function syncNode() {
|
||||
const form = $("#install-form");
|
||||
if (!installer.nodeEdited) form.elements.name.value = slug(form.elements.friendly_name.value);
|
||||
$("#node-preview").textContent = form.elements.name.value || "…";
|
||||
}
|
||||
function renderTargets(ports) {
|
||||
const select = $("#install-target");
|
||||
const key = ports.join("\n");
|
||||
if (installer.ports === key) return;
|
||||
installer.ports = key;
|
||||
const current = select.value;
|
||||
const later = node("option", "Later · alleen het profiel bewaren");
|
||||
later.value = "";
|
||||
select.replaceChildren(...ports.map((p) => { const o = node("option", portLabel(p)); o.value = p; return o; }), later);
|
||||
select.value = ports.includes(current) ? current : ports[0] || "";
|
||||
renderTargetHint();
|
||||
}
|
||||
function renderTargetHint() {
|
||||
const select = $("#install-target");
|
||||
const ports = select.options.length - 1;
|
||||
$("#target-hint").textContent = !ports
|
||||
? "Geen USB-poort gevonden. Sluit het scherm met een datakabel aan op de Home Assistant-machine; de lijst ververst vanzelf."
|
||||
: !select.value
|
||||
? "Het profiel komt in de ESPHome-map. Installeren kan later via Firmware & USB, of vanuit ESPHome Device Builder."
|
||||
: ports > 1
|
||||
? "Meer dan één bord aangesloten: kies de poort van dit scherm."
|
||||
: "Eén keer via USB; daarna gaat alles draadloos.";
|
||||
$("#install-go").textContent = select.value ? "Installeren" : "Profiel bewaren";
|
||||
}
|
||||
function renderWifi(wifi) {
|
||||
const fields = $("#wifi-fields");
|
||||
const ask = wifi?.state === "new" || wifi?.state === "missing";
|
||||
fields.hidden = !ask;
|
||||
fields.disabled = !ask;
|
||||
if (ask) {
|
||||
const missing = wifi.missing || [];
|
||||
$("#wifi-ssid-label").hidden = !missing.includes("wifi_ssid");
|
||||
$("#wifi-password-label").hidden = !missing.includes("wifi_password");
|
||||
$("#wifi-status").textContent = wifi.state === "new"
|
||||
? "Eén keer invullen: ESP Screens bewaart dit in ESPHome secrets.yaml, volgende schermen gebruiken het automatisch."
|
||||
: "Je ESPHome secrets.yaml mist nog wifi-gegevens. ESP Screens vult alleen de ontbrekende regels aan.";
|
||||
}
|
||||
return wifi?.state === "ready"
|
||||
? "Wifi komt uit je ESPHome secrets.yaml."
|
||||
: wifi?.state === "invalid"
|
||||
? "secrets.yaml in de ESPHome-map is geen geldige YAML. Herstel het bestand eerst; het wordt niet overschreven."
|
||||
: "";
|
||||
}
|
||||
async function installerRefresh() {
|
||||
let data;
|
||||
try {
|
||||
const {wifi} = await (await api("firmware")).json();
|
||||
if (wifi?.state === "ready") {
|
||||
status.textContent = "✓ Bestaande ESPHome-wifi gevonden. Dit scherm gebruikt automatisch wifi_ssid en wifi_password uit je secrets.yaml.";
|
||||
$("#create-profile").disabled = false;
|
||||
} else if (wifi?.state === "new") {
|
||||
status.textContent = "Nog geen ESPHome-secrets gevonden. Vul wifi één keer in; volgende schermen gebruiken deze gegevens automatisch.";
|
||||
fields.hidden = false;
|
||||
fields.disabled = false;
|
||||
$("#create-profile").disabled = false;
|
||||
} else {
|
||||
status.textContent = "Controleer wifi_ssid en wifi_password in ESPHome secrets.yaml. Je bestaande bestand blijft behouden. Sluit en open deze wizard opnieuw na aanpassen.";
|
||||
}
|
||||
} catch {
|
||||
status.textContent = "ESPHome-secrets konden niet worden gecontroleerd. Sluit en open de wizard opnieuw om het nogmaals te proberen.";
|
||||
data = await (await api("firmware")).json();
|
||||
} catch (e) {
|
||||
$("#install-note").textContent = e.message;
|
||||
return;
|
||||
}
|
||||
const job = data.job;
|
||||
const ours = job && installer.file && job.file === installer.file;
|
||||
if (ours) installer.jobState = job.state;
|
||||
if (installer.view === "setup") {
|
||||
if (ours && job.state === "running") return showProgress(job, data.logs);
|
||||
renderTargets(data.ports || []);
|
||||
const wifiNote = renderWifi(data.wifi);
|
||||
const busy = job?.state === "running";
|
||||
const flashing = !!$("#install-target").value;
|
||||
$("#install-note").textContent = busy
|
||||
? `Er loopt al een build of installatie (${job.file}). Wacht tot die klaar is.`
|
||||
: !data.available && flashing
|
||||
? "ESPHome CLI ontbreekt in deze installatie; alleen het profiel bewaren kan."
|
||||
: wifiNote;
|
||||
$("#install-go").disabled = data.wifi?.state === "invalid" || (flashing && (busy || !data.available));
|
||||
} else if (installer.view === "progress" && ours) {
|
||||
renderProgress(job, data.logs);
|
||||
}
|
||||
}
|
||||
$("#new-screen").onclick = $("#start").onclick = () => {
|
||||
function showProgress(job, logs) {
|
||||
installer.view = "progress";
|
||||
installer.jobState = job.state;
|
||||
$("#install-setup").hidden = true;
|
||||
$("#install-progress").hidden = false;
|
||||
$("#install-log-wrap").hidden = false;
|
||||
renderProgress(job, logs);
|
||||
}
|
||||
function outcome(ok) {
|
||||
$("#progress-spin").hidden = true;
|
||||
$("#progress-mark").hidden = false;
|
||||
$("#progress-mark").textContent = ok ? "✓" : "✕";
|
||||
$("#progress-mark").className = `outcome ${ok ? "ok" : "bad"}`;
|
||||
}
|
||||
function renderProgress(job, logs) {
|
||||
const running = job.state === "running";
|
||||
const ok = job.state === "success";
|
||||
if (running) {
|
||||
$("#progress-spin").hidden = false;
|
||||
$("#progress-mark").hidden = true;
|
||||
} else outcome(ok);
|
||||
$("#install-title").textContent = running ? "Even geduld…" : ok ? "Klaar." : "Dat lukte niet.";
|
||||
$("#progress-title").textContent = running
|
||||
? job.stage === "upload" ? `Firmware naar ${installer.friendly} schrijven…` : "Firmware bouwen…"
|
||||
: ok ? `Firmware staat op ${installer.friendly}` : "Installeren mislukt";
|
||||
$("#progress-detail").textContent = running
|
||||
? job.stage === "upload"
|
||||
? "Haal de USB-kabel nog niet los."
|
||||
: "Een eerste build duurt op een Raspberry enkele minuten. Je mag dit venster sluiten: de installatie loopt door en je vindt hem terug onder Nieuw scherm."
|
||||
: ok
|
||||
? `Het scherm start op en verbindt met je wifi.${installer.board === "cyd" ? " De CYD vraagt eerst om een touch-kalibratie: tik de kruisjes aan." : ""} Koppel het nu aan Home Assistant:`
|
||||
: logs.filter((l) => /error/i.test(l)).pop() || logs.filter((l) => /failed|mislukt|fout/i.test(l)).pop() || "Bekijk het log hieronder.";
|
||||
const pre = $("#install-log");
|
||||
const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8;
|
||||
pre.textContent = logs.join("\n");
|
||||
if (stick) pre.scrollTop = pre.scrollHeight;
|
||||
if (!running && !ok) $("#install-log-wrap").open = true;
|
||||
$("#install-result").hidden = !ok;
|
||||
if (ok) renderSteps();
|
||||
$("#install-retry").hidden = running || ok;
|
||||
$("#install-close").textContent = ok ? "Klaar" : "Sluiten";
|
||||
$("#install-close").classList.toggle("quiet", !ok);
|
||||
}
|
||||
function showSaved() {
|
||||
installer.view = "done";
|
||||
$("#install-setup").hidden = true;
|
||||
$("#install-progress").hidden = false;
|
||||
outcome(true);
|
||||
$("#install-title").textContent = "Profiel bewaard.";
|
||||
$("#progress-title").textContent = `${installer.file} staat in de ESPHome-map`;
|
||||
$("#progress-detail").textContent =
|
||||
"Installeren kan zodra het scherm aan de Home Assistant-machine hangt: Firmware & USB → dit profiel → USB-poort → Bouwen & installeren. Of open het profiel in ESPHome Device Builder (dezelfde map) en flash vanuit je browser. Bewaar de API-sleutel voor de koppeling:";
|
||||
$("#install-result").hidden = false;
|
||||
renderSteps();
|
||||
$("#install-log-wrap").hidden = true;
|
||||
$("#install-retry").hidden = true;
|
||||
$("#install-close").textContent = "Klaar";
|
||||
$("#install-close").classList.remove("quiet");
|
||||
}
|
||||
function renderSteps() {
|
||||
$("#api-key").textContent = installer.apiKey || "";
|
||||
const steps = [
|
||||
["Ga naar Home Assistant → Instellingen → Apparaten & diensten.", ` Dit gebeurt buiten ESP Screens. Home Assistant ontdekt ${installer.friendly} als ESPHome-apparaat; klik op Toevoegen. Niet ontdekt? Voeg ESPHome handmatig toe met het IP-adres van het scherm. `],
|
||||
["Plak de API-sleutel", " hierboven zodra Home Assistant om een encryptiesleutel vraagt."],
|
||||
["Sta HA-acties toe:", " ESPHome-integratie → Configureren → “Allow the device to perform Home Assistant actions”. Zonder dit ziet het scherm alles, maar bedient het niets."],
|
||||
["Kies je tegels.", " Terug in ESP Screens verschijnt het scherm binnen een halve minuut in de lijst links; tot die tijd staat het daar als “nog niet in Home Assistant”."],
|
||||
];
|
||||
$("#install-steps").replaceChildren(...steps.map(([b, t], i) => {
|
||||
const li = node("li");
|
||||
li.append(node("b", b), t);
|
||||
if (i === 0) {
|
||||
const go = node("button", "Open Apparaten & diensten", "mini");
|
||||
go.type = "button";
|
||||
go.onclick = openIntegrations;
|
||||
li.append(go);
|
||||
}
|
||||
return li;
|
||||
}));
|
||||
}
|
||||
function resetInstaller() {
|
||||
Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, ports: null, jobState: null, target: "" });
|
||||
const form = $("#install-form");
|
||||
form.reset();
|
||||
$("#node-label").hidden = true;
|
||||
$("#install-setup").hidden = false;
|
||||
$("#install-progress").hidden = true;
|
||||
$("#install-result").hidden = true;
|
||||
$("#install-log-wrap").open = false;
|
||||
$("#install-log").textContent = "";
|
||||
$("#install-title").textContent = "Aansluiten en installeren.";
|
||||
$("#install-status").textContent = "";
|
||||
$("#install-note").textContent = "";
|
||||
$("#install-target").replaceChildren();
|
||||
$("#target-hint").textContent = "";
|
||||
$("#install-close").classList.add("quiet");
|
||||
syncNode();
|
||||
}
|
||||
function openInstaller() {
|
||||
if (installer.view !== "progress") resetInstaller();
|
||||
$("#installer").showModal();
|
||||
checkWifiSecrets();
|
||||
installerRefresh();
|
||||
clearInterval(installer.poll);
|
||||
installer.poll = setInterval(installerRefresh, 3000);
|
||||
}
|
||||
$("#new-screen").onclick = $("#start").onclick = openInstaller;
|
||||
$("#close-install").onclick = $("#install-close").onclick = () => $("#installer").close();
|
||||
$("#installer").addEventListener("close", () => {
|
||||
clearInterval(installer.poll);
|
||||
// A finished job is shown once; the next open starts a fresh form.
|
||||
if (installer.view === "progress" && installer.jobState !== "running") installer.view = "done";
|
||||
});
|
||||
$("#install-target").onchange = () => { renderTargetHint(); installerRefresh(); };
|
||||
$("#install-form").elements.friendly_name.oninput = syncNode;
|
||||
$("#install-form").elements.name.oninput = () => { installer.nodeEdited = true; syncNode(); };
|
||||
$("#edit-node").onclick = () => {
|
||||
installer.nodeEdited = true;
|
||||
$("#node-label").hidden = false;
|
||||
$("#install-form").elements.name.focus();
|
||||
};
|
||||
$("#close-install").onclick = () => $("#installer").close();
|
||||
let generatedName = "";
|
||||
$("#install-form").onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
$("#download").disabled = true;
|
||||
const form = e.target;
|
||||
syncNode();
|
||||
if (!form.reportValidity()) return;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
$("#install-go").disabled = true;
|
||||
$("#install-status").textContent = "";
|
||||
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;
|
||||
const result = await (await api("firmware/profiles", { method: "POST", body: JSON.stringify(data) })).json();
|
||||
Object.assign(installer, { file: result.file, apiKey: result.api_key, friendly: data.friendly_name.trim(), board: data.board, target: data.target });
|
||||
form.elements.wifi_password.value = "";
|
||||
if (result.job) showProgress(result.job, []);
|
||||
else showSaved();
|
||||
} catch (err) {
|
||||
$("#install-status").textContent = err.message;
|
||||
} finally {
|
||||
$("#download").disabled = false;
|
||||
$("#install-go").disabled = false;
|
||||
}
|
||||
};
|
||||
$("#copy-yaml").onclick = async () => {
|
||||
$("#install-retry").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.";
|
||||
const { ports } = await (await api("firmware")).json();
|
||||
// The board may have been replugged; a single visible port is unambiguous.
|
||||
if (!ports.includes(installer.target) && ports.length === 1) installer.target = ports[0];
|
||||
const job = await (await api("firmware/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ file: installer.file, action: "install", target: installer.target }),
|
||||
})).json();
|
||||
showProgress(job, []);
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
};
|
||||
$("#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.";
|
||||
};
|
||||
$("#copy-key").onclick = () => copyText(installer.apiKey || "", $("#api-key"));
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (dirty) {
|
||||
e.preventDefault();
|
||||
@@ -1184,26 +1404,6 @@ for (const action of ["validate", "build", "install"])
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
$("#create-profile").onclick = async () => {
|
||||
const form = $("#install-form");
|
||||
if (!form.reportValidity()) return;
|
||||
try {
|
||||
const data = await (
|
||||
await api("firmware/profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
})
|
||||
).json();
|
||||
generatedName = data.file.replace(/\.yaml$/, " ").trim();
|
||||
$("#yaml-output").value = data.yaml;
|
||||
$("#yaml-result").hidden = false;
|
||||
$("#install-status").textContent =
|
||||
`${data.file} bewaard. Open Firmware & USB om te installeren.`;
|
||||
form.elements.wifi_password.value = "";
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
function openSection(id) {
|
||||
const section = $(id);
|
||||
if (section.tagName === "DETAILS") section.open = true;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<button id="new-screen" class="tool tool-primary">
|
||||
<span class="tool-icon" aria-hidden="true">+</span>
|
||||
<span class="tool-text"
|
||||
><b>Nieuw scherm</b><small>Koppel een CYD of Guition</small></span
|
||||
><b>Nieuw scherm</b><small>Aansluiten en installeren</small></span
|
||||
>
|
||||
</button>
|
||||
<button id="open-firmware" class="tool">
|
||||
@@ -47,9 +47,10 @@
|
||||
</div>
|
||||
<ol class="howto" aria-label="Zo werkt het">
|
||||
<li>
|
||||
<b>1 · Koppelen</b><span
|
||||
>Kies <b>Nieuw scherm</b> voor de installatie-YAML met eigen
|
||||
sleutels. Eén keer via USB flashen, daarna gaat alles draadloos.</span
|
||||
<b>1 · Installeren</b><span
|
||||
>Sluit het scherm via USB aan op de Home Assistant-machine en kies
|
||||
<b>Nieuw scherm</b>: naam, wifi en klaar. Eén keer via USB, daarna
|
||||
gaat alles draadloos.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
@@ -61,7 +62,8 @@
|
||||
<li>
|
||||
<b>3 · Opslaan</b><span
|
||||
>Met <b>Opslaan & naar scherm</b> staat het direct op je scherm.
|
||||
Nieuwe schermfuncties installeer je via <b>Firmware & USB</b>.</span
|
||||
Nieuwe schermfuncties komen met <b>Bijwerken</b> draadloos op je
|
||||
scherm.</span
|
||||
>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -69,9 +71,10 @@
|
||||
<aside>
|
||||
<h2>Mijn schermen</h2>
|
||||
<div id="screens"></div>
|
||||
<div id="pending"></div>
|
||||
<p class="hint">
|
||||
Nieuw scherm gekoppeld? Het verschijnt hier automatisch binnen 30
|
||||
seconden.
|
||||
Nieuw scherm gekoppeld in Home Assistant? Het verschijnt hier
|
||||
automatisch binnen 30 seconden.
|
||||
</p>
|
||||
<button class="quiet" id="refresh">↻ Vernieuwen</button>
|
||||
<div class="updates" id="updates" hidden>
|
||||
@@ -93,8 +96,8 @@
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Begin met een scherm</h2>
|
||||
<p>
|
||||
Sluit een CYD of Guition aan. We maken de installatie-YAML voor je
|
||||
en helpen je met de eerste flash.
|
||||
Sluit een CYD of Guition via USB aan op je Home Assistant-machine.
|
||||
Profiel, firmware en de eerste flash gebeuren in één venster.
|
||||
</p>
|
||||
<button id="start">Nieuw scherm installeren</button>
|
||||
</section>
|
||||
@@ -184,8 +187,8 @@
|
||||
</section>
|
||||
</div>
|
||||
<p class="firmware-link">
|
||||
Firmware bouwen en via USB of wifi installeren kan via
|
||||
<b>Firmware & USB</b> bovenaan. Je bestaande YAML en secrets blijven
|
||||
Een bestaand profiel opnieuw bouwen of via USB of wifi installeren kan
|
||||
via <b>Firmware & USB</b> bovenaan. Je eigen YAML en secrets blijven
|
||||
bewaard.
|
||||
</p>
|
||||
<details class="help" id="help">
|
||||
@@ -206,11 +209,13 @@
|
||||
tegel op <b>Dubbelbreed</b> voor meer ruimte.
|
||||
</p>
|
||||
<p>
|
||||
Nieuwe firmware bouw en installeer je via <b>Firmware & USB</b> met
|
||||
de ingebouwde ESPHome CLI: kies je eigen profiel, dan een USB-poort of
|
||||
wifi, en klik op <b>Bouwen & installeren</b>. Je tegels blijven in
|
||||
deze app bewaard; een offline scherm krijgt de wijzigingen zodra het
|
||||
terug is.
|
||||
Een nieuw scherm installeer je via <b>Nieuw scherm</b>: het profiel
|
||||
met eigen sleutels, ontbrekende wifi in ESPHome secrets.yaml, de build
|
||||
en de USB-flash gebeuren in één venster, met de API-sleutel voor de
|
||||
koppeling aan het eind. Nieuwe firmware voor bestaande schermen komt
|
||||
via <b>Bijwerken</b> in de lijst, of handmatig via
|
||||
<b>Firmware & USB</b>. Je tegels blijven in deze app bewaard; een
|
||||
offline scherm krijgt de wijzigingen zodra het terug is.
|
||||
</p>
|
||||
</details>
|
||||
</main>
|
||||
@@ -219,7 +224,7 @@
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<p class="eyebrow">NIEUW SCHERM</p>
|
||||
<h2>Van USB naar aan de muur.</h2>
|
||||
<h2 id="install-title">Aansluiten en installeren.</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -230,104 +235,102 @@
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Kies je scherm. Elk bestand krijgt eigen sleutels voor een veilige
|
||||
koppeling en draadloze updates.
|
||||
</p>
|
||||
<fieldset>
|
||||
<legend>Welk scherm heb je?</legend>
|
||||
<label class="board"
|
||||
><input type="radio" name="board" value="cyd" checked /><span
|
||||
><b>CYD · 2,8 inch</b
|
||||
><small>ESP32-2432S028 · 320 × 240</small></span
|
||||
></label
|
||||
><label class="board"
|
||||
><input type="radio" name="board" value="guition" /><span
|
||||
><b>Guition · 4 inch</b
|
||||
><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span
|
||||
<div id="install-setup">
|
||||
<p>
|
||||
Sluit het scherm met een USB-datakabel aan op de machine waarop Home
|
||||
Assistant draait. ESP Screens maakt een eigen profiel met unieke
|
||||
sleutels, bouwt de firmware en zet die direct op het scherm.
|
||||
</p>
|
||||
<fieldset>
|
||||
<legend>Welk scherm heb je?</legend>
|
||||
<label class="board"
|
||||
><input type="radio" name="board" value="cyd" checked /><span
|
||||
><b>CYD · 2,8 inch</b
|
||||
><small>ESP32-2432S028 · 320 × 240</small></span
|
||||
></label
|
||||
><label class="board"
|
||||
><input type="radio" name="board" value="guition" /><span
|
||||
><b>Guition · 4 inch</b
|
||||
><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
<label
|
||||
>Naam<input
|
||||
name="friendly_name"
|
||||
required
|
||||
maxlength="60"
|
||||
placeholder="Scherm woonkamer"
|
||||
autocomplete="off" /><small class="node-line"
|
||||
>Apparaatnaam <code id="node-preview">scherm</code
|
||||
><button type="button" class="link" id="edit-node">
|
||||
aanpassen
|
||||
</button></small
|
||||
><small
|
||||
>Elk scherm krijgt een eigen profiel met eigen sleutels. Vier
|
||||
schermen? Doorloop dit vier keer met een andere naam.</small
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
<label
|
||||
>Herkenbare naam<input
|
||||
name="friendly_name"
|
||||
required
|
||||
maxlength="60"
|
||||
placeholder="Scherm woonkamer" /></label
|
||||
><label
|
||||
>Unieke apparaatnaam<input
|
||||
name="name"
|
||||
required
|
||||
pattern="[a-z][a-z0-9-]{0,29}"
|
||||
maxlength="30"
|
||||
placeholder="scherm-woonkamer"
|
||||
/><small
|
||||
>Kleine letters, cijfers en streepjes. Elk scherm een andere
|
||||
naam.</small
|
||||
></label>
|
||||
<p id="wifi-status" role="status"></p>
|
||||
<fieldset id="wifi-fields" hidden disabled>
|
||||
<legend>Wifi voor je eerste ESPHome-apparaat</legend>
|
||||
<label
|
||||
>Wifi-naam<input
|
||||
name="wifi_ssid"
|
||||
autocomplete="off"
|
||||
/></label>
|
||||
<label
|
||||
>Wifiwachtwoord<input
|
||||
name="wifi_password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
</fieldset>
|
||||
<button type="button" id="create-profile">
|
||||
Bewaar profiel in ESP Screens
|
||||
</button>
|
||||
<button type="submit" id="download">Maak installatie-YAML</button>
|
||||
<p id="install-status" role="status"></p>
|
||||
<div id="yaml-result" hidden>
|
||||
<label id="node-label" hidden
|
||||
>Apparaatnaam<input
|
||||
name="name"
|
||||
pattern="[a-z][a-z0-9-]{0,29}"
|
||||
maxlength="30"
|
||||
autocomplete="off" /><small
|
||||
>Kleine letters, cijfers en streepjes. Elk scherm een andere
|
||||
naam.</small
|
||||
></label
|
||||
>
|
||||
<fieldset id="wifi-fields" class="wifi" hidden disabled>
|
||||
<legend>Wifi</legend>
|
||||
<p class="hint" id="wifi-status"></p>
|
||||
<label id="wifi-ssid-label"
|
||||
>Wifi-naam (2,4 GHz)<input name="wifi_ssid" autocomplete="off"
|
||||
/></label>
|
||||
<label id="wifi-password-label"
|
||||
>Wifiwachtwoord<input
|
||||
name="wifi_password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
</fieldset>
|
||||
<label
|
||||
>Je eigen ESPHome-YAML<textarea
|
||||
id="yaml-output"
|
||||
readonly
|
||||
rows="9"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="yaml-actions">
|
||||
<button type="button" id="copy-yaml">Kopieer YAML</button
|
||||
><button type="button" class="quiet" id="download-yaml">
|
||||
Download bestand
|
||||
>Installeren via<select name="target" id="install-target"></select
|
||||
><small id="target-hint"></small
|
||||
></label>
|
||||
<p class="hint" id="install-note"></p>
|
||||
<div class="install-actions">
|
||||
<button type="submit" id="install-go">Installeren</button>
|
||||
<span id="install-status" role="status"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="install-progress" hidden>
|
||||
<div class="progress-head">
|
||||
<span class="spin" id="progress-spin"></span>
|
||||
<span class="outcome" id="progress-mark" hidden></span>
|
||||
<strong id="progress-title"></strong>
|
||||
</div>
|
||||
<p id="progress-detail"></p>
|
||||
<div id="install-result" hidden>
|
||||
<div class="key-box">
|
||||
<span>API-sleutel</span><code id="api-key"></code>
|
||||
<button type="button" class="mini" id="copy-key">Kopieer</button>
|
||||
</div>
|
||||
<ol class="steps" id="install-steps"></ol>
|
||||
</div>
|
||||
<details id="install-log-wrap">
|
||||
<summary>Log van ESPHome</summary>
|
||||
<pre id="install-log"></pre>
|
||||
</details>
|
||||
<div class="install-actions">
|
||||
<button type="button" id="install-retry" hidden>
|
||||
Opnieuw proberen
|
||||
</button>
|
||||
<button type="button" class="quiet" id="install-close">
|
||||
Sluiten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<b>Bewaar je profiel.</b> Bestaande wifi-secrets worden behouden;
|
||||
bij een eerste installatie vul je wifi hierboven in.
|
||||
</li>
|
||||
<li>
|
||||
<b>Open Firmware & USB.</b> Kies dit profiel, sluit het scherm op je
|
||||
Raspberry aan en selecteer de juiste USB-poort. Kies Bouwen &
|
||||
installeren.
|
||||
</li>
|
||||
<li>
|
||||
<b>Koppel in HA → Apparaten & diensten → ESPHome.</b> Gebruik de
|
||||
API-sleutel uit je eigen YAML en sta HA-acties toe.
|
||||
</li>
|
||||
<li>
|
||||
<b>Kies hier je tegels.</b> CYD vraagt bij de eerste start om
|
||||
kalibratie; Guition niet.
|
||||
</li>
|
||||
<li>
|
||||
<b>Later updaten?</b> Kies hetzelfde profiel en het IP-adres van je
|
||||
scherm in Firmware & USB.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="hint">
|
||||
Bewaar je YAML met sleutels. Gebruik later Firmware & USB met het
|
||||
IP-adres voor draadloze updates. Tegels wijzigen doe je hier.
|
||||
</p>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="firmware-dialog">
|
||||
|
||||
@@ -989,3 +989,145 @@ pre {
|
||||
.icon-choice.icon-auto .mdi { font-size: 22px; }
|
||||
.icon-picker .hint { margin: 0; }
|
||||
.icon-hint { font-weight: 400; }
|
||||
|
||||
/* Nieuw scherm: profiel, wifi en de eerste flash in één venster. */
|
||||
.link {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
padding: 0 0 0 8px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
.node-line code {
|
||||
background: var(--soft);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#installer .wifi {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 16px 2px;
|
||||
border: 1px solid #f4e3c2;
|
||||
background: #fff9ee;
|
||||
border-radius: 12px;
|
||||
}
|
||||
#installer .wifi legend {
|
||||
padding: 0 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
#installer .wifi .hint {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
#installer select {
|
||||
margin-top: 7px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 10px;
|
||||
border-color: #cdd5df;
|
||||
}
|
||||
.install-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
#install-status {
|
||||
color: #b23b3b;
|
||||
font-size: 13px;
|
||||
}
|
||||
#install-note:empty {
|
||||
display: none;
|
||||
}
|
||||
.progress-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 17px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.progress-head .spin {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-width: 3px;
|
||||
}
|
||||
.outcome {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
.outcome.ok {
|
||||
color: #21704b;
|
||||
}
|
||||
.outcome.bad {
|
||||
color: #b23b3b;
|
||||
}
|
||||
.key-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #f7f9fc;
|
||||
margin: 4px 0 16px;
|
||||
}
|
||||
.key-box span {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
.key-box code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
}
|
||||
#install-log-wrap {
|
||||
margin-top: 12px;
|
||||
}
|
||||
#install-log-wrap summary {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
#install-log {
|
||||
max-height: 240px;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
/* Profiles that Home Assistant does not list yet: pairing happens outside this page. */
|
||||
#pending:empty {
|
||||
display: none;
|
||||
}
|
||||
.pending {
|
||||
border: 1px dashed #b9cbec;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
.pending strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
.pending small {
|
||||
margin: 3px 0 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.pending-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.pending-actions .quiet {
|
||||
padding: 5px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.steps .mini {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user