Rebuild the editor as a Vue app: sidebar, pages side by side, tile settings in a drawer (0.2.74)
The page is now web/ (Vue 3 + Vite + TypeScript), built into screen_manager/app/static with hashed file names; server.py serves index.html as built and /assets/. The Python API, the data and the firmware are unchanged. Your screens sit in a sidebar, the pages of a screen stand next to each other at the screen's own shape, the library sits on the right, and a tile's settings slide in as a drawer while the mockup stays in view. New screen, Firmware & USB, Alerts, Override YAML and Settings are pages of their own. Fonts moved to web/src/assets; the tests read the source through tests/editor_sources.py. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
// Perform action (app 0.2.67): the actions Home Assistant offers for a tile's entity, under its own names,
|
||||
// and one field per value Home Assistant asks for (its selector). An empty field is left out.
|
||||
import { computed } from "vue";
|
||||
import { loadEntityActions, markDirty, state, supports } from "../store";
|
||||
import type { EntityAction, Tile } from "../types";
|
||||
import Segmented from "./Segmented.vue";
|
||||
|
||||
const props = defineProps<{ tile: Tile }>();
|
||||
const list = computed(() => state.entityActions[props.tile.entity]);
|
||||
const chosen = computed(() => props.tile.options?.action);
|
||||
const entry = computed(() => list.value?.find((a) => a.action === chosen.value?.action));
|
||||
const open = computed(() => state.actionPickerOpen || !chosen.value);
|
||||
const rows = computed(() => {
|
||||
const q = state.actionSearch.trim().toLocaleLowerCase();
|
||||
return (list.value || []).filter((a) => `${a.name} ${a.action} ${a.description}`.toLocaleLowerCase().includes(q));
|
||||
});
|
||||
if (list.value === undefined) loadEntityActions(props.tile.entity);
|
||||
|
||||
function pick(action: EntityAction) {
|
||||
const same = action.action === chosen.value?.action;
|
||||
props.tile.options = { ...props.tile.options, action: same ? chosen.value : { action: action.action } };
|
||||
state.actionPickerOpen = false;
|
||||
markDirty();
|
||||
}
|
||||
function setField(key: string, value: unknown) {
|
||||
const data: Record<string, unknown> = { ...(props.tile.options?.action?.data || {}) };
|
||||
if (value === undefined) delete data[key];
|
||||
else data[key] = value;
|
||||
props.tile.options = { ...props.tile.options, action: { action: chosen.value!.action, ...(Object.keys(data).length ? { data } : {}) } };
|
||||
markDirty();
|
||||
}
|
||||
const missing = computed(() => {
|
||||
if (!entry.value) return [];
|
||||
const data = props.tile.options?.action?.data || {};
|
||||
return entry.value.fields.filter((f) => f.required && data[f.key] === undefined).map((f) => f.name);
|
||||
});
|
||||
type Field = EntityAction["fields"][number];
|
||||
const kindOf = (field: Field) => Object.keys(field.selector || {})[0] || "text";
|
||||
const configOf = (field: Field) => (field.selector || {})[kindOf(field)] || {};
|
||||
const exampleOf = (field: Field) => (field.example === undefined ? "" : typeof field.example === "string" ? field.example : JSON.stringify(field.example));
|
||||
// A select's own options, or the values this entity has where Home Assistant asks for one of them (a source, an effect).
|
||||
function choicesOf(field: Field): [string, string][] | null {
|
||||
const config = configOf(field), kind = kindOf(field);
|
||||
if (Array.isArray(field.options)) return field.options.map((o) => [o, o]);
|
||||
if (kind === "select" && Array.isArray(config.options) && !config.multiple)
|
||||
return config.options.map((o: any) => (o && typeof o === "object" ? [String(o.value), String(o.label ?? o.value)] : [String(o), String(o)]));
|
||||
return null;
|
||||
}
|
||||
const hex = (rgb: number[]) => "#" + rgb.map((c) => Math.max(0, Math.min(255, Number(c) || 0)).toString(16).padStart(2, "0")).join("");
|
||||
const colorValue = (value: unknown) => (Array.isArray(value) && value.length === 3 ? hex(value as number[]) : "#ffffff");
|
||||
const textValue = (value: unknown) => (value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value));
|
||||
function onText(field: Field, raw: string) {
|
||||
const text = raw.trim();
|
||||
if (!text) return setField(field.key, undefined);
|
||||
if (kindOf(field) === "text") return setField(field.key, raw);
|
||||
try { setField(field.key, JSON.parse(text)); } catch { setField(field.key, raw); }
|
||||
}
|
||||
function onNumber(field: Field, raw: string) {
|
||||
setField(field.key, raw === "" || !Number.isFinite(Number(raw)) ? undefined : Number(raw));
|
||||
}
|
||||
const unitOf = (field: Field) => configOf(field).unit_of_measurement || (kindOf(field) === "color_temp" ? configOf(field).unit || "" : "");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Action</span>
|
||||
<small v-if="list === undefined">Asking Home Assistant which actions this entity has…</small>
|
||||
<small v-else-if="list === null" class="warn">Home Assistant isn't answering right now. Try again in a moment.</small>
|
||||
<template v-else>
|
||||
<button type="button" class="row" :aria-expanded="open ? 'true' : 'false'" @click="state.actionPickerOpen = !open">
|
||||
<span class="tx">
|
||||
<b>{{ entry ? entry.name : chosen ? chosen.action : "Choose an action" }}</b>
|
||||
<small v-if="chosen" class="mono">{{ chosen.action }}</small>
|
||||
</span>
|
||||
<span class="link">{{ open ? "Close" : "Change" }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="picker">
|
||||
<input v-model="state.actionSearch" type="search" placeholder="Search, for example toggle, position, or play" aria-label="Search for an action" />
|
||||
<div class="action-list">
|
||||
<button v-for="action in rows" :key="action.action" type="button" class="action-choice" :aria-pressed="action.action === chosen?.action ? 'true' : 'false'" @click="pick(action)">
|
||||
<strong>{{ action.name }}</strong>
|
||||
<small>{{ action.description ? `${action.action} · ${action.description}` : action.action }}</small>
|
||||
</button>
|
||||
<p v-if="!rows.length" class="hint">No action found.</p>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="chosen && !entry" class="warn">Home Assistant doesn't offer this action for this entity any more, so a tap does nothing. Choose another one.</small>
|
||||
<template v-if="entry && !open">
|
||||
<div v-for="field in entry.fields" :key="field.key" class="f">
|
||||
<span class="f-label">{{ field.required ? field.name : `${field.name} (optional)` }}</span>
|
||||
<Segmented v-if="kindOf(field) === 'boolean'" :choices="[['', 'Not set'], ['true', 'On'], ['false', 'Off']]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
@pick="(v) => setField(field.key, v === '' ? undefined : v === 'true')" />
|
||||
<Segmented v-else-if="choicesOf(field) && choicesOf(field)!.length <= 4" :choices="[['', 'Not set'], ...choicesOf(field)!]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
|
||||
@pick="(v) => setField(field.key, v === '' ? undefined : v)" />
|
||||
<select v-else-if="choicesOf(field)" :aria-label="field.name" :value="String(chosen?.data?.[field.key] ?? '')" @change="setField(field.key, ($event.target as HTMLSelectElement).value === '' ? undefined : ($event.target as HTMLSelectElement).value)">
|
||||
<option value="">Not set</option>
|
||||
<option v-for="[key, text] in choicesOf(field)!" :key="key" :value="key">{{ text }}</option>
|
||||
</select>
|
||||
<div v-else-if="kindOf(field) === 'color_rgb'" class="action-number">
|
||||
<input type="color" :aria-label="field.name" :value="colorValue(chosen?.data?.[field.key])" @input="setField(field.key, [1, 3, 5].map((i) => parseInt(($event.target as HTMLInputElement).value.slice(i, i + 2), 16)))" />
|
||||
<button type="button" class="btn quiet mini" @click="setField(field.key, undefined)">Not set</button>
|
||||
</div>
|
||||
<div v-else-if="kindOf(field) === 'number' || kindOf(field) === 'color_temp'" class="action-number">
|
||||
<input type="number" :aria-label="field.name" :min="configOf(field).min" :max="configOf(field).max" :step="configOf(field).step === 'any' ? 'any' : configOf(field).step"
|
||||
:value="chosen?.data?.[field.key] ?? ''" :placeholder="exampleOf(field)" @input="onNumber(field, ($event.target as HTMLInputElement).value)" />
|
||||
<span v-if="unitOf(field)">{{ unitOf(field) }}</span>
|
||||
</div>
|
||||
<input v-else type="text" :aria-label="field.name" :value="textValue(chosen?.data?.[field.key])" :placeholder="exampleOf(field)" @input="onText(field, ($event.target as HTMLInputElement).value)" />
|
||||
<small v-if="field.description">{{ field.description }}</small>
|
||||
</div>
|
||||
<small v-if="missing.length" class="warn">Home Assistant needs {{ missing.join(", ") }}.</small>
|
||||
</template>
|
||||
<small v-if="!supports(0, 2, 58)">The screen performs an action from firmware 0.2.58: press Update on the screen. Until then a tap works as Automatic.</small>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
// Alerts: the cheatsheet for esphome.<node>_show_alert, built from the inventory.
|
||||
import { computed, ref } from "vue";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, state } from "../store";
|
||||
|
||||
const alerts = computed(() => state.inventory.alerts);
|
||||
const icons = computed(() => state.inventory.icons);
|
||||
const exampleAction = ref("");
|
||||
const iconQuery = ref("");
|
||||
const yamlString = (text: unknown) => `"${String(text).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
const fieldValue = (f: any) => f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : f.example === true ? "true" : f.example === false ? "false" : String(f.example);
|
||||
const screensWithAction = computed(() => state.inventory.screens.filter((s) => s.alert_action));
|
||||
const chosenAction = computed(() => exampleAction.value || screensWithAction.value[0]?.alert_action || "");
|
||||
const exampleYaml = computed(() => {
|
||||
const lines = (alerts.value?.fields || []).map((f: any) => ` ${f.name}: ${fieldValue(f)}`);
|
||||
return `action: ${chosenAction.value || "esphome.<device_name>_show_alert"}\ndata:\n${lines.join("\n")}`;
|
||||
});
|
||||
const waitYaml = computed(() => [
|
||||
`# Doorbell: show the alert and wait until someone presses the button.`,
|
||||
`actions:`,
|
||||
` - action: ${chosenAction.value || "esphome.<device_name>_show_alert"}`,
|
||||
` data:`,
|
||||
` title: "Someone is at the door"`,
|
||||
` subtitle: "Door 3, back"`,
|
||||
` icon: doorbell`,
|
||||
` color: orange`,
|
||||
` button_text: "Coming"`,
|
||||
` timeout: 0`,
|
||||
` flash: true`,
|
||||
` - wait_for_trigger:`,
|
||||
` - trigger: event`,
|
||||
` event_type: ${alerts.value?.event || "esphome.screen_alert"}`,
|
||||
` event_data:`,
|
||||
` action: ok`,
|
||||
` timeout: "00:05:00"`,
|
||||
` - if:`,
|
||||
` - condition: template`,
|
||||
` value_template: "{{ wait.trigger is not none }}"`,
|
||||
` then:`,
|
||||
` - action: notify.notify`,
|
||||
` data:`,
|
||||
` message: "Someone is coming to the door."`,
|
||||
].join("\n"));
|
||||
// One event for every screen (app 0.2.45): an action for "Edit in YAML" of the Event action.
|
||||
const allYaml = computed(() => {
|
||||
const lines = (alerts.value?.fields || []).map((f: any) => ` ${f.name}: ${fieldValue(f)}`);
|
||||
const camera = alerts.value?.camera;
|
||||
if (camera) lines.push(` # ${camera.name}: ${camera.example} # a Guition shows its picture on the card`);
|
||||
return `event: ${alerts.value?.broadcast?.show || "esp_screens_show_alert"}\nevent_data:\n${lines.join("\n")}`;
|
||||
});
|
||||
const iconGroups = computed(() => {
|
||||
if (!alerts.value || !icons.value) return [];
|
||||
const query = iconQuery.value.trim().toLowerCase();
|
||||
const all = [...icons.value.groups, { label: "Also available: control and weather icons", icons: alerts.value.extra_icons }];
|
||||
return all.map((group) => ({ label: group.label, icons: group.icons.filter((i: any) => !query || i.name.includes(query) || (i.label || "").toLowerCase().includes(query)) })).filter((g) => g.icons.length);
|
||||
});
|
||||
const doorbell = computed(() => alerts.value?.suggested_icons?.find((i: any) => i.name === "doorbell"));
|
||||
const orange = computed(() => alerts.value?.colors?.find((c: any) => c.name === "orange"));
|
||||
const types: Record<string, string> = { string: "text", int: "number", bool: "on / off" };
|
||||
function jump(id: string) { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); }
|
||||
const sections = [["alerts-screens", "Your screens"], ["alerts-howto", "In an automation"], ["alerts-all", "All screens"], ["alerts-fields", "The fields"], ["alerts-icons", "Icons"], ["alerts-colors", "Colors"], ["alerts-behaviour", "Behavior"], ["alerts-events", "Events"], ["alerts-tips", "Tips"]];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel wide alerts" id="alerts-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Alerts · cheatsheet</span>
|
||||
<h1>A full-screen notification, from an automation.</h1>
|
||||
<p>Every screen has the <code>show_alert</code> action. It places a card over everything, wakes the screen, and keeps the backlight at normal brightness until someone taps the button or the timeout runs out. Home Assistant itself shows no explanation or dropdowns for ESPHome actions; everything you can fill in is listed here.</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-alerts" @click="go('')">← Back</button>
|
||||
</div>
|
||||
<p v-if="!alerts" class="hint">The cheatsheet is still loading; one moment.</p>
|
||||
<template v-else>
|
||||
<div class="alert-hero">
|
||||
<div class="alert-mock" id="alert-mock" aria-hidden="true">
|
||||
<div class="alert-mock-card" :style="orange ? { background: orange.color } : undefined">
|
||||
<span class="mdi alert-mock-icon" id="alert-mock-icon">{{ doorbell ? glyph(doorbell.cp) : "" }}</span>
|
||||
<strong id="alert-mock-title">Someone is at the door</strong>
|
||||
<span id="alert-mock-subtitle">Door 3, back</span>
|
||||
<span class="fake" id="alert-mock-button">Coming</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="alerts-nav" aria-label="Sections">
|
||||
<button v-for="[id, label] in sections" :key="id" type="button" :data-jump="id" @click="jump(id)">{{ label }}</button>
|
||||
</nav>
|
||||
</div>
|
||||
<section id="alerts-screens" class="card">
|
||||
<h2>Your screens</h2>
|
||||
<p>Every screen has its own action, with the device name in it. From firmware <code id="alerts-min-firmware">{{ alerts.min_firmware }}</code>; update an older screen with its Update button under Screens.</p>
|
||||
<div id="alerts-screen-list" class="options">
|
||||
<p v-if="!state.inventory.screens.length" class="hint">No screen paired yet. Install one via New screen; the action appears once Home Assistant sees the screen.</p>
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="alert-screen">
|
||||
<div class="alert-screen-head">
|
||||
<strong>{{ screen.name }}</strong>
|
||||
<span class="chip" :class="versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
|
||||
{{ versionAtLeast(screen.firmware, alerts.min_firmware) && screen.alert_action ? `● firmware ${screen.firmware}` : screen.alert_action ? `Update needed · firmware ${screen.firmware || "unknown"}` : "Device name unknown · update the screen" }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="[label, action] in [['Show alert', screen.alert_action], ['Dismiss alert', screen.dismiss_action]]" :key="label" class="copy-line">
|
||||
<span class="copy-label">{{ label }}</span><code>{{ action || "esphome.<device_name>_show_alert" }}</code>
|
||||
<button v-if="action" type="button" class="btn quiet mini" @click="copyText(action!, undefined, 'Action name')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-howto" class="card">
|
||||
<h2>In an automation</h2>
|
||||
<ol class="steps">
|
||||
<li><b>Add action</b> and search for <b>ESPHome</b>. Choose your screen's action: Home Assistant calls it "Performs the action show_alert of the node <i>device name</i>".</li>
|
||||
<li><b>Fill in the fields.</b> Home Assistant asks for all seven; leave what you don't use empty (<code>""</code>, <code>0</code>, off).</li>
|
||||
<li>Prefer YAML? Choose <b>Edit in YAML</b> on the action and paste the example below.</li>
|
||||
</ol>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-example-screen">Example for</label>
|
||||
<select id="alerts-example-screen" v-model="exampleAction" style="max-width: 360px">
|
||||
<option v-for="screen in screensWithAction" :key="screen.id" :value="screen.alert_action">{{ screen.name }}</option>
|
||||
<option v-if="!screensWithAction.length" value="">a screen (not paired yet)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-example">{{ exampleYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-example-copy" @click="copyText(exampleYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-all" class="card">
|
||||
<h2>All screens at once</h2>
|
||||
<p>Fire the event <code id="alerts-all-event">{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code> with the same fields, and ESP Screens passes the alert on to every screen that is online, including screens you add later. <code id="alerts-all-dismiss">{{ alerts.broadcast?.dismiss || "esp_screens_dismiss_alert" }}</code> clears it everywhere. In an automation, add the <b>Event</b> action or paste the example under <b>Edit in YAML</b>. Leave out the fields you don't use. ESP Screens has to be running for this.</p>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-all-example">{{ allYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-all-copy" @click="copyText(allYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-fields" class="card">
|
||||
<h2>The fields</h2>
|
||||
<p>Text is free-form; the limit is in bytes, and an accented letter counts as two. What doesn't fit gets an ellipsis (title) or is dropped (subtitle).</p>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-field-table">
|
||||
<tr><th>Field</th><th>Type</th><th>What it does</th><th>Example</th><th>Limit</th></tr>
|
||||
<tr v-for="field in alerts.fields" :key="field.name">
|
||||
<td><code>{{ field.name }}</code><small>{{ field.label }}</small></td>
|
||||
<td>{{ types[field.type] || field.type }}</td>
|
||||
<td>{{ field.help }}</td>
|
||||
<td><code>{{ typeof field.example === "string" ? field.example : String(field.example) }}</code></td>
|
||||
<td>{{ alerts.limits.cyd[field.name] ? `CYD ${alerts.limits.cyd[field.name]} · Guition ${alerts.limits.guition[field.name]} bytes` : field.type === "int" ? "0 to 86400 s" : "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="alerts.camera">
|
||||
<td><code>{{ alerts.camera.name }}</code><small>{{ alerts.camera.label }}</small></td>
|
||||
<td>entity</td>
|
||||
<td>{{ alerts.camera.help }}</td>
|
||||
<td><code>{{ alerts.camera.example }}</code></td>
|
||||
<td>Guition, firmware 0.2.57+</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-icons" class="card">
|
||||
<h2>Icons</h2>
|
||||
<p>Fill in the name in <code>icon</code>, for example <code>doorbell</code>. <code>mdi:doorbell</code> and the hex codepoint (<code>F12E6</code>) also work. Only these icons are included in the firmware; any other name shows the warning triangle. Tap an icon to copy its name.</p>
|
||||
<p class="subhead">Handy for notifications</p>
|
||||
<div class="chips" id="alerts-suggested">
|
||||
<button v-for="icon in alerts.suggested_icons" :key="icon.name" type="button" class="chip" @click="copyText(icon.name, undefined, 'Icon name')"><span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code></button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="alerts-icon-search">Search for an icon</label>
|
||||
<input id="alerts-icon-search" v-model="iconQuery" type="search" placeholder="Search by name or description…" autocomplete="off" />
|
||||
</div>
|
||||
<div id="alerts-icon-groups">
|
||||
<div v-for="group in iconGroups" :key="group.label" class="field">
|
||||
<p class="subhead">{{ group.label }} · {{ group.icons.length }}</p>
|
||||
<div class="alert-icon-grid">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="alert-icon" :title="`Copy ${icon.name}`" @click="copyText(icon.name, undefined, 'Icon name')">
|
||||
<span class="mdi">{{ glyph(icon.cp) }}</span><code>{{ icon.name }}</code><small v-if="icon.label">{{ icon.label }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!iconGroups.length" class="hint">No icon for "{{ iconQuery }}". Unknown names show the warning triangle ({{ alerts.fallback_icon }}).</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-colors" class="card">
|
||||
<h2>Colors</h2>
|
||||
<p>Fill in the name in <code>color</code>. These are the pastel shades of the tiles, with dark text. Empty or unknown gives the white card. Tap to copy.</p>
|
||||
<div class="swatches" id="alerts-swatches">
|
||||
<button type="button" class="swatch" @click="copyText('', undefined, 'Empty color')"><i style="background: #ffffff"></i><span><code>empty</code><small>White (default)</small></span></button>
|
||||
<button v-for="colour in alerts.colors" :key="colour.name" type="button" class="swatch" @click="copyText(colour.name, undefined, 'Color name')"><i :style="{ background: colour.color }"></i><span><code>{{ colour.name }}</code><small>{{ colour.label }} · {{ colour.color }}</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-behaviour" class="card">
|
||||
<h2>Behavior</h2>
|
||||
<ul class="alerts-list">
|
||||
<li><b>Wakes the screen.</b> An alert immediately wakes a dimmed or dark screen and appears above every page, every open light card, and the standby layer.</li>
|
||||
<li><b>On until the button.</b> As long as the card is showing, the backlight stays at normal brightness. Standby and night hours wait. After the button, the regular standby time starts again.</li>
|
||||
<li><b>Timeout.</b> <code>timeout: 0</code> waits for the button, however long that takes. With <code>timeout: 60</code> the card disappears after a minute, but the button closes it sooner.</li>
|
||||
<li><b>Blinking.</b> <code>flash: true</code> blinks the backlight four times when the alert arrives (about a second) and leaves the screen on afterward.</li>
|
||||
<li><b>Replacing.</b> A new alert replaces the current one; there's no stack. The replaced alert reports itself as <code>replaced</code>.</li>
|
||||
<li><b>Closing remotely.</b> <code>dismiss_alert</code> clears the card, for example if the door is already open. Without an alert, that action does nothing.</li>
|
||||
<li><b>Tapping beside the card</b> does nothing; only the button closes it. That way a wake-up tap never accidentally dismisses the alert.</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="alerts-events" class="card">
|
||||
<h2>Events</h2>
|
||||
<p>Every end fires the event <code id="alerts-event-name">{{ alerts.event }}</code> with <code>action</code>, <code>title</code>, <code>screen</code> (the device name) and the <code>device_id</code> that Home Assistant adds. Visible under Settings → Tools → Events.</p>
|
||||
<div class="table-scroll">
|
||||
<table id="alerts-ending-table">
|
||||
<tr><th>action</th><th>When</th></tr>
|
||||
<tr v-for="ending in alerts.endings" :key="ending.action"><td><code>{{ ending.action }}</code></td><td>{{ ending.label }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<p class="subhead">Waiting until someone presses the button</p>
|
||||
<div class="copy-line">
|
||||
<pre id="alerts-wait-example">{{ waitYaml }}</pre>
|
||||
<button type="button" class="btn quiet mini" id="alerts-wait-copy" @click="copyText(waitYaml, undefined, 'YAML')">Copy YAML</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="alerts-tips" class="card">
|
||||
<h2>Tips</h2>
|
||||
<ul class="alerts-list">
|
||||
<li>Use a <b>color per kind of notification</b>: orange for the door, red for smoke or water, green for "done". The text always stays dark and readable.</li>
|
||||
<li>Keep the title <b>short</b>; a screen shows about twenty characters on one line and ends a longer title with an ellipsis. Put details in the subtitle.</li>
|
||||
<li>A <b>custom button text</b> turns the notification into a question: "Coming", "Seen", "Turn off". The event then tells you who responded.</li>
|
||||
<li>For an alert that resolves itself (the door opens, the laundry gets taken out) close it in the same automation with <code>dismiss_alert</code>.</li>
|
||||
<li>Every screen? Fire <code>{{ alerts.broadcast?.show || "esp_screens_show_alert" }}</code> once (see All screens at once). The screens still work independently and each reports with its own <code>screen</code>.</li>
|
||||
<li>Rather <b>ask Claude</b> for the automation? Install the ESP Screens skill under Settings → Claude.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
// Everything around the screens: firmware updates, alerts, Claude.
|
||||
import { computed, ref } from "vue";
|
||||
import { go, installClaudeSkill, runUpdateAll, setAutoUpdate, state } from "../store";
|
||||
|
||||
const u = computed(() => state.inventory.updates);
|
||||
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
|
||||
const updatesHint = computed(() => !u.value ? "" : u.value.busy
|
||||
? `Updating to firmware ${u.value.target}…`
|
||||
: !state.inventory.screens.length
|
||||
? `No screen paired yet. New screens get firmware ${u.value.target}.`
|
||||
: outdated.value
|
||||
? `Firmware ${u.value.target} is available for ${outdated.value} screen${outdated.value === 1 ? "" : "s"}.`
|
||||
: `All screens have firmware ${u.value.target}.`);
|
||||
const skill = computed(() => state.inventory.claude_skill);
|
||||
const installing = ref(false);
|
||||
async function install() {
|
||||
installing.value = true;
|
||||
try { await installClaudeSkill(); } finally { installing.value = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="settings-view">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Settings</span>
|
||||
<h1 id="settings-title">Everything around your screens.</h1>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-settings" @click="go('')">← My screens</button>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<section class="card">
|
||||
<h2>Screens</h2>
|
||||
<p>Add a screen, or build and reinstall the firmware of one you already have.</p>
|
||||
<div class="tools">
|
||||
<button type="button" class="tool" @click="go('#new-screen')"><span class="tool-icon">+</span><span class="tx"><b>New screen</b><small>Connect and install</small></span></button>
|
||||
<button type="button" class="tool" @click="go('#firmware')"><span class="tool-icon">⇪</span><span class="tx"><b>Firmware & USB</b><small>Build and install via USB or Wi-Fi</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="u" class="card updates" id="updates">
|
||||
<h2>Firmware updates</h2>
|
||||
<p id="updates-hint">{{ updatesHint }}</p>
|
||||
<button v-if="u.pending && !u.busy && u.pending >= 2" id="update-all" type="button" class="btn primary" @click="runUpdateAll">Update all {{ u.pending }} screens</button>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="auto-update" :checked="Boolean(u.auto)" @change="setAutoUpdate(($event.target as HTMLInputElement).checked)" />
|
||||
<span>Update automatically every night<small>Between 03:00 and 06:00, one screen at a time. Stops on an error and reports it in Home Assistant.</small></span>
|
||||
</label>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>Alerts</h2>
|
||||
<p>A card over the whole screen from an automation, on one screen or on all of them. The cheatsheet has the action names, fields, icons, and colors.</p>
|
||||
<div class="tools">
|
||||
<button type="button" class="tool" @click="go('#alerts')"><span class="tool-icon">!</span><span class="tx"><b>Alerts</b><small>Full-screen notifications, from an automation</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card" id="claude">
|
||||
<h2>Claude</h2>
|
||||
<p>Teach Claude about ESP Screens, then ask: "Show an alert on all my screens when the mailbox is full." Claude knows the alert events, every field, color, and icon.</p>
|
||||
<div class="actions">
|
||||
<button type="button" id="claude-install" class="btn" :class="skill?.installed && skill.current ? 'quiet' : 'primary'" :disabled="!skill || installing" @click="install">
|
||||
{{ !skill || !skill.installed ? "Install for Claude Code" : skill.current ? "Install again" : "Update the skill" }}
|
||||
</button>
|
||||
<span id="claude-status" class="chip" :class="!skill ? '' : !skill.installed ? '' : skill.current ? 'good' : 'update'" role="status">
|
||||
{{ !skill ? "Loading…" : !skill.installed ? "Not installed" : skill.current ? "● Installed" : "Installed · a newer version is ready" }}
|
||||
</span>
|
||||
</div>
|
||||
<small>For Claude Code in Home Assistant. Writes one file to <code id="claude-path">{{ skill?.path || "/homeassistant/.claude/skills/esp-screens" }}</code>; installing again replaces it.</small>
|
||||
<div class="actions">
|
||||
<a class="btn quiet" id="claude-download" href="api/claude-skill.zip" download="esp-screens.zip">Download for claude.ai</a>
|
||||
<small>Upload the zip in Claude under Customize → Skills.</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
// One page of the screen as the mockup draws it: the top bar and a 2 × 3 grid of cells.
|
||||
import { computed } from "vue";
|
||||
import { isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { isGuition, openBar, removePage, state } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
import TileCard from "./TileCard.vue";
|
||||
import TopbarSvg from "./TopbarSvg.vue";
|
||||
|
||||
const props = defineProps<{ page: number; entries: { tile: Tile; slot: number }[]; pages: number; moving: Tile | null }>();
|
||||
const bySlot = computed(() => new Map(props.entries.map((e) => [e.slot, e])));
|
||||
const covered = computed(() => new Set(props.entries.filter((e) => isWide(e.tile)).map((e) => e.slot + 1)));
|
||||
const cells = computed(() => Array.from({ length: SLOTS_PER_PAGE }, (_, cell) => props.page * SLOTS_PER_PAGE + cell).filter((slot) => !covered.value.has(slot)));
|
||||
const empty = computed(() => props.pages > 1 && !props.entries.some((e) => pageOf(e.slot) === props.page));
|
||||
const barSelected = computed(() => state.inspector?.kind === "bar" || state.inspector?.kind === "bar-add");
|
||||
const filled = computed(() => props.entries.filter((e) => pageOf(e.slot) === props.page).reduce((n, e) => n + (isWide(e.tile) ? 2 : 1), 0));
|
||||
function pickCell(slot: number) {
|
||||
const marked = state.insertAt === slot;
|
||||
state.insertAt = marked ? -1 : slot;
|
||||
if (state.insertAt >= 0) document.querySelector<HTMLInputElement>("#search")?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-label">
|
||||
<span>Page {{ page + 1 }}</span>
|
||||
<button v-if="empty" type="button" class="btn mini" title="The pages after this one shift up one slot" @click="removePage(page)">Remove page</button>
|
||||
<span v-else>{{ filled }} / {{ SLOTS_PER_PAGE }}</span>
|
||||
</div>
|
||||
<div class="device" :class="{ cyd: !isGuition }">
|
||||
<div class="bar-wrap" :class="{ selected: barSelected }" title="Edit top bar" role="button" tabindex="0"
|
||||
@click="openBar(0)" @keydown.enter.prevent="openBar(0)">
|
||||
<TopbarSvg />
|
||||
</div>
|
||||
<div class="tiles">
|
||||
<template v-for="slot in cells" :key="slot">
|
||||
<TileCard v-if="bySlot.get(slot)" :tile="bySlot.get(slot)!.tile" :slot="slot" :placeholder="bySlot.get(slot)!.tile === moving" />
|
||||
<button v-else type="button" class="cell" :class="{ 'insert-here': state.insertAt === slot }" :data-slot="slot"
|
||||
title="Empty slot. Click to add a tile here, or drag one over."
|
||||
:aria-label="`Empty slot ${(slot % SLOTS_PER_PAGE) + 1} on page ${page + 1}: add the next tile here`" @click="pickCell(slot)">
|
||||
<span>+</span><small>{{ state.insertAt === slot ? "Next tile goes here" : "Empty" }}</small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// The inspector slides in from the right and leaves the mockup visible; its content follows what is selected.
|
||||
import { computed } from "vue";
|
||||
import { currentTile, state } from "../store";
|
||||
import TileInspector from "./TileInspector.vue";
|
||||
import TopbarInspector from "./TopbarInspector.vue";
|
||||
import TopbarAdd from "./TopbarAdd.vue";
|
||||
import InspectPanel from "./InspectPanel.vue";
|
||||
|
||||
const open = computed(() => Boolean(state.inspector && (state.inspector.kind !== "tile" || currentTile.value)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="drawer" id="tile-sheet" :class="{ open }" :aria-hidden="open ? 'false' : 'true'" @click.stop>
|
||||
<template v-if="state.inspector">
|
||||
<TileInspector v-if="state.inspector.kind === 'tile' && currentTile" :tile="currentTile" />
|
||||
<TopbarInspector v-else-if="state.inspector.kind === 'bar'" :index="state.inspector.index" />
|
||||
<TopbarAdd v-else-if="state.inspector.kind === 'bar-add'" />
|
||||
<InspectPanel v-else-if="state.inspector.kind === 'inspect'" :entity="state.inspector.entity" />
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { go, state } from "../store";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="state.inventory.screens.length" id="choose" class="empty">
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Choose a screen</h2>
|
||||
<p>Pick one of your screens on the left to change its tiles, top bar and settings.</p>
|
||||
</section>
|
||||
<section v-else id="empty" class="empty">
|
||||
<span class="empty-icon">▦</span>
|
||||
<h2>Start with a screen</h2>
|
||||
<p>Connect a CYD or Guition via USB to your Home Assistant machine. Profile, firmware, and the first flash happen in one go.</p>
|
||||
<button id="start" type="button" class="btn primary" @click="go('#new-screen')">Install new screen</button>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
// Shared firmware workspace; always a concrete profile and upload target.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { go, toast } from "../store";
|
||||
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
const data = ref<any>(null);
|
||||
const file = ref("");
|
||||
const target = ref("ota");
|
||||
const host = ref("");
|
||||
let timer = 0;
|
||||
async function refreshFirmware(initial = false) {
|
||||
try {
|
||||
const next = await getJson("firmware");
|
||||
data.value = next;
|
||||
if (initial || !file.value) file.value = next.profiles[0]?.file || "";
|
||||
// USB stays chosen while the board is replugged; a port that went away falls back to the first one.
|
||||
const ports: string[] = next.ports || [];
|
||||
if (target.value.startsWith("/") && !ports.includes(target.value)) target.value = ports[0] || "usb";
|
||||
if (target.value === "usb" && ports.length) target.value = ports[0];
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
const running = computed(() => data.value?.job?.state === "running");
|
||||
const disabled = computed(() => !data.value || running.value || !data.value.available || !data.value.profiles?.length);
|
||||
const installDisabled = computed(() => disabled.value || target.value === "usb");
|
||||
const ports = computed<string[]>(() => data.value?.ports || []);
|
||||
const statusText = computed(() => !data.value ? "Loading…" : !data.value.available
|
||||
? "The ESPHome CLI is missing. Update the app to 0.2.0."
|
||||
: data.value.job
|
||||
? `${data.value.job.file} · ${data.value.job.action} · ${data.value.job.state}`
|
||||
: "Choose the intended profile, then a USB port, an IP address, or Download.");
|
||||
const downloadReady = computed(() => target.value === "download" && !!file.value && !!data.value?.downloads?.includes(file.value));
|
||||
const image = computed(() => ({ href: `api/firmware/profiles/${encodeURIComponent(file.value)}/download`, name: file.value.replace(/\.yaml$/, "") + ".factory.bin" }));
|
||||
async function run(action: "validate" | "build" | "install") {
|
||||
try {
|
||||
await send("firmware/jobs", "POST", {
|
||||
file: file.value,
|
||||
action: action === "validate" ? "validate" : action === "build" ? "build" : target.value === "download" ? "download" : "install",
|
||||
target: target.value === "ota" ? host.value.trim() : target.value === "download" ? "" : target.value,
|
||||
});
|
||||
await refreshFirmware();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
onMounted(() => { refreshFirmware(true); timer = window.setInterval(() => refreshFirmware(), 3000); });
|
||||
onBeforeUnmount(() => clearInterval(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="firmware-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Firmware & USB</span>
|
||||
<h1>Build and install screen firmware</h1>
|
||||
<p>The built-in ESPHome CLI uses your own profile (YAML with keys) and secrets. Choose the screen's profile, then a USB port (screen connected to the Home Assistant machine), <b>Wi-Fi / OTA</b> with the hostname, or <b>Download</b> to put the firmware on the screen from your own computer. <b>Check</b> only validates, <b>Build only</b> compiles, <b>Build & install</b> puts the firmware on the screen. Your tiles stay as they are in ESP Screens. The first build takes a few minutes.</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-firmware" @click="go('')">← Back</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-grid">
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-file">ESPHome profile</label>
|
||||
<select id="firmware-file" v-model="file">
|
||||
<option v-for="p in data?.profiles || []" :key="p.file" :value="p.file">{{ p.file }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="f-label" for="firmware-port">Install to</label>
|
||||
<select id="firmware-port" v-model="target">
|
||||
<option value="ota">Wi-Fi / OTA</option>
|
||||
<option v-if="!ports.length" value="usb">USB · no board found on the Home Assistant machine yet</option>
|
||||
<option v-for="p in ports" :key="p" :value="p">{{ p }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="target === 'ota'" class="field" id="firmware-host-label">
|
||||
<label class="f-label" for="firmware-host">IP address or hostname</label>
|
||||
<input id="firmware-host" v-model="host" :placeholder="file ? file.replace(/\.yaml$/, '.local') : 'screen-livingroom.local'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet" id="firmware-validate" :disabled="disabled" @click="run('validate')">Check</button>
|
||||
<button type="button" class="btn quiet" id="firmware-build" :disabled="disabled" @click="run('build')">Build only</button>
|
||||
<button type="button" class="btn primary" id="firmware-install" :disabled="installDisabled" @click="run('install')">{{ target === "download" ? "Build & download" : "Build & install" }}</button>
|
||||
<span v-if="running" class="spin"></span>
|
||||
</div>
|
||||
<p id="firmware-status" class="status-line" role="status">{{ statusText }}</p>
|
||||
<div v-if="downloadReady" id="firmware-download" class="card" style="background: var(--surface-2)">
|
||||
<a class="btn primary" id="firmware-download-link" :href="image.href" :download="image.name">Download {{ image.name }}</a>
|
||||
<small>Put it on the screen with <a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a> in Chrome or Edge: Connect, choose the screen's USB port, Install, and select this file. It holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="firmware-log" class="log">{{ (data?.logs || []).join("\n") }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
// The icon choice for tiles and top bar items: automatic, optionally none, or one from the set.
|
||||
// A choice applies live; the open picker keeps its search and scroll position.
|
||||
import { computed, ref } from "vue";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { iconNamed, state } from "../store";
|
||||
|
||||
const props = defineProps<{ selected: string; automatic: string; autoLabel: string; allowNone?: boolean; note?: string }>();
|
||||
const emit = defineEmits<{ (e: "pick", name: string): void }>();
|
||||
const query = ref("");
|
||||
const chosen = computed(() => iconNamed(props.selected));
|
||||
const currentGlyph = computed(() => (props.selected === "none" ? "" : glyph(chosen.value?.cp || props.automatic)));
|
||||
const currentText = computed(() => (props.selected === "none" ? "No icon" : chosen.value?.label || props.autoLabel));
|
||||
const groups = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
return (state.inventory.icons?.groups || []).map((group) => ({
|
||||
label: group.label,
|
||||
icons: group.icons.filter((i) => !q || `${i.label} ${i.name.replaceAll("-", " ")} ${group.label}`.toLocaleLowerCase().includes(q)),
|
||||
})).filter((g) => g.icons.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="f">
|
||||
<span class="f-label">Icon</span>
|
||||
<button type="button" class="row" :aria-expanded="state.iconPickerOpen ? 'true' : 'false'" @click="state.iconPickerOpen = !state.iconPickerOpen">
|
||||
<span class="av mdi">{{ currentGlyph }}</span>
|
||||
<span class="tx"><b>{{ currentText }}</b></span>
|
||||
<span class="link">{{ state.iconPickerOpen ? "Close" : "Change" }}</span>
|
||||
</button>
|
||||
<div v-if="state.iconPickerOpen" class="picker">
|
||||
<input v-model="query" type="search" placeholder="Search, for example lamp, music, or door" aria-label="Search for an icon" />
|
||||
<button type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'auto' ? 'true' : 'false'" :title="autoLabel" @click="emit('pick', 'auto')">
|
||||
<span class="mdi">{{ glyph(automatic) }}</span><span>{{ autoLabel }}</span>
|
||||
</button>
|
||||
<button v-if="allowNone" type="button" class="icon-choice icon-auto" :aria-pressed="selected === 'none' ? 'true' : 'false'" title="No icon" @click="emit('pick', 'none')">
|
||||
<span class="mdi"></span><span>No icon, text only</span>
|
||||
</button>
|
||||
<div class="icon-list">
|
||||
<section v-for="group in groups" :key="group.label">
|
||||
<small>{{ group.label }}</small>
|
||||
<div class="icon-grid">
|
||||
<button v-for="icon in group.icons" :key="icon.name" type="button" class="icon-choice" :title="icon.label" :aria-label="`Icon: ${icon.label}`"
|
||||
:aria-pressed="selected === icon.name ? 'true' : 'false'" @click="emit('pick', icon.name)"><span class="mdi">{{ glyph(icon.cp) }}</span></button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="!groups.length" class="hint">No icon found.</p>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="note">{{ note }}</small>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
// Read current data: what Home Assistant reports for the screen's tiles right now, and how each tile is set.
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { getJson } from "../api";
|
||||
import { controlsLabel, displayNames } from "../model/layout";
|
||||
import { closeInspector, state, toast } from "../store";
|
||||
|
||||
const props = defineProps<{ entity?: string }>();
|
||||
const summary = ref<any[] | null>(null);
|
||||
const raw = ref("Reading…");
|
||||
const error = ref("");
|
||||
async function load() {
|
||||
if (!state.selected) return;
|
||||
summary.value = null;
|
||||
error.value = "";
|
||||
raw.value = "Fetching current HA status...";
|
||||
try {
|
||||
const data = await getJson(`screens/${encodeURIComponent(state.selected)}/inspect`);
|
||||
const tiles = props.entity ? data.tiles.filter((t: any) => t.entity === props.entity) : data.tiles;
|
||||
summary.value = tiles;
|
||||
raw.value = JSON.stringify(props.entity ? tiles[0] : data, null, 2);
|
||||
} catch (e: any) {
|
||||
error.value = e.message;
|
||||
raw.value = "";
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
const optionsText = (entity: string) => {
|
||||
const options = state.layout?.tiles.find((t) => t.entity === entity)?.options || {};
|
||||
return `Small slider: ${options.inline === "slider" ? "yes" : "no"} · Display: ${displayNames[options.display || "standard"] || options.display} · Width: ${options.size === "wide" ? "double" : "normal"} · Control: ${controlsLabel({ entity, name: "", slot: 0, options }, state.inventory)} · Background: ${state.inventory.backgrounds?.[options.background || "auto"]?.label || "Default"}`;
|
||||
};
|
||||
onMounted(load);
|
||||
watch(() => props.entity, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av" style="background: var(--seg); color: var(--ink-2); font-size: 16px">⌕</span>
|
||||
<span class="tx"><b>Current data</b><small :class="{ mono: entity }">{{ entity || "Connection, status, and supported properties" }}</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body" id="inspector-section">
|
||||
<div id="inspection-summary">
|
||||
<p v-if="error" class="hint warn">{{ error }}</p>
|
||||
<p v-else-if="!summary" class="hint">Fetching current HA status...</p>
|
||||
<article v-for="tile in summary || []" :key="tile.entity" class="inspection-tile">
|
||||
<strong>{{ tile.entity }}</strong>
|
||||
<span>Status: {{ tile.word || tile.state }}</span>
|
||||
<small>{{ optionsText(tile.entity) }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<pre id="inspection">{{ raw }}</pre>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn quiet" @click="load">Read again</button>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
// New screen: profile, Wi-Fi, and the first flash in one go.
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { copyText, go, openIntegrations, toast } from "../store";
|
||||
|
||||
// Download: ESP Screens builds, the owner flashes the file from their own computer. ESPHome Web is ESPHome's own
|
||||
// browser flasher; this address opens it with its hint for a downloaded project (as ESPHome Device Builder does).
|
||||
const ESPHOME_WEB = "https://web.esphome.io/?dashboard_install";
|
||||
const DOWNLOAD_TARGET = "Download · flash from your own computer";
|
||||
const form = reactive({ board: "cyd", friendly_name: "", name: "", wifi_ssid: "", wifi_password: "", target: "" });
|
||||
const installer = reactive({
|
||||
view: "setup" as "setup" | "progress" | "done", file: null as string | null, friendly: "", board: "cyd", target: "",
|
||||
apiKey: null as string | null, nodeEdited: false, jobState: null as string | null, picked: false, action: null as string | null,
|
||||
});
|
||||
const nodeVisible = ref(false);
|
||||
const data = ref<any>(null);
|
||||
const job = ref<any>(null);
|
||||
const logs = ref<string[]>([]);
|
||||
const note = ref("");
|
||||
const status = ref("");
|
||||
const submitting = ref(false);
|
||||
const logOpen = ref(false);
|
||||
const keyBox = ref<HTMLElement | null>(null);
|
||||
let poll = 0;
|
||||
|
||||
// ESPHome's node-name rule: lowercase ASCII, digits and dashes, starting with a letter.
|
||||
function slug(text: string) {
|
||||
const clean = text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").slice(0, 30).replace(/-+$/, "");
|
||||
return clean || "screen";
|
||||
}
|
||||
function portLabel(port: string) {
|
||||
const id = port.replace(/^\/dev\/serial\/by-id\/usb-/, "").replace(/-if\d+(-port\d+)?$/, "").replace(/_/g, " ");
|
||||
return `USB · ${id === port ? port.replace(/^\/dev\//, "") : id}`;
|
||||
}
|
||||
watch(() => form.friendly_name, () => { if (!installer.nodeEdited) form.name = slug(form.friendly_name); });
|
||||
const nodePreview = computed(() => form.name || "…");
|
||||
const ports = computed<string[]>(() => data.value?.ports || []);
|
||||
const wifi = computed(() => data.value?.wifi);
|
||||
const askWifi = computed(() => wifi.value?.state === "new" || wifi.value?.state === "missing");
|
||||
const wifiMissing = computed<string[]>(() => wifi.value?.missing || []);
|
||||
const wifiStatus = computed(() => wifi.value?.state === "new"
|
||||
? "Fill in once: ESP Screens saves this in ESPHome secrets.yaml, later screens use it automatically."
|
||||
: "Your ESPHome secrets.yaml is still missing Wi-Fi details. ESP Screens only fills in the missing lines.");
|
||||
const wifiNote = computed(() => wifi.value?.state === "ready"
|
||||
? "Wi-Fi comes from your ESPHome secrets.yaml."
|
||||
: wifi.value?.state === "invalid"
|
||||
? "secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten."
|
||||
: "");
|
||||
const targetHint = computed(() => form.target === "usb"
|
||||
? "Connect the screen with a USB data cable to the Home Assistant machine; the list refreshes on its own. Or choose Download to use your own computer."
|
||||
: form.target === "download"
|
||||
? "Download the firmware and put it on the screen from your own computer, in Chrome or Edge. After that, updates go over Wi-Fi."
|
||||
: !form.target
|
||||
? "The profile goes into the ESPHome folder. You can install later via Firmware & USB, or from ESPHome Device Builder."
|
||||
: ports.value.length > 1
|
||||
? "More than one board connected: choose this screen's port."
|
||||
: "Once over USB; after that, everything is wireless.");
|
||||
const goLabel = computed(() => (form.target === "download" ? "Build & download" : form.target ? "Install" : "Save profile"));
|
||||
const busyElsewhere = computed(() => data.value?.job?.state === "running" && !(installer.file && data.value.job.file === installer.file));
|
||||
const goDisabled = computed(() => submitting.value || wifi.value?.state === "invalid" || form.target === "usb" ||
|
||||
(!!form.target && (busyElsewhere.value || !data.value?.available)));
|
||||
// USB on the Home Assistant machine is always listed first, also before a board is plugged in, so nobody
|
||||
// concludes it isn't possible; "usb" stands for that port until one shows up.
|
||||
function syncTarget() {
|
||||
const current = form.target;
|
||||
const kept = current === "download" || current === "" ? installer.picked : ports.value.includes(current);
|
||||
if (!kept) form.target = ports.value[0] || "usb";
|
||||
}
|
||||
async function installerRefresh() {
|
||||
let next: any;
|
||||
try {
|
||||
next = await getJson("firmware");
|
||||
} catch (e: any) {
|
||||
note.value = e.message;
|
||||
return;
|
||||
}
|
||||
data.value = next;
|
||||
const current = next.job;
|
||||
const ours = current && installer.file && current.file === installer.file;
|
||||
if (ours) installer.jobState = current.state;
|
||||
if (installer.view === "setup") {
|
||||
if (ours && current.state === "running") { showProgress(current, next.logs || []); return; }
|
||||
syncTarget();
|
||||
note.value = busyElsewhere.value
|
||||
? `A build or installation is already running (${current.file}). Wait for it to finish.`
|
||||
: !next.available && form.target
|
||||
? "The ESPHome CLI is missing from this installation; only saving the profile is possible."
|
||||
: wifiNote.value;
|
||||
} else if (installer.view === "progress" && ours) {
|
||||
job.value = current;
|
||||
logs.value = next.logs || [];
|
||||
if (current.state !== "running" && current.state !== "success") logOpen.value = true;
|
||||
}
|
||||
}
|
||||
function showProgress(current: any, lines: string[]) {
|
||||
installer.view = "progress";
|
||||
installer.jobState = current.state;
|
||||
installer.action = current.action;
|
||||
job.value = current;
|
||||
logs.value = lines;
|
||||
}
|
||||
const running = computed(() => job.value?.state === "running");
|
||||
const ok = computed(() => installer.view === "done" || job.value?.state === "success");
|
||||
const download = computed(() => installer.action === "download");
|
||||
const title = computed(() => installer.view === "done" ? "Profile saved." : running.value ? "One moment…" : ok.value ? (download.value ? "Ready to download." : "Done.") : "That didn't work.");
|
||||
const progressTitle = computed(() => installer.view === "done"
|
||||
? `${installer.file} is in the ESPHome folder`
|
||||
: running.value
|
||||
? job.value?.stage === "upload" ? `Writing firmware to ${installer.friendly}…` : "Building firmware…"
|
||||
: ok.value
|
||||
? download.value ? `Firmware for ${installer.friendly} is ready` : `Firmware is on ${installer.friendly}`
|
||||
: download.value ? "Build failed" : "Install failed");
|
||||
const progressDetail = computed(() => installer.view === "done"
|
||||
? "Install it later from Firmware & USB: choose this profile, then the USB port of the Home Assistant machine, or Download to put the firmware on the screen from your own computer. Save the API key for pairing:"
|
||||
: running.value
|
||||
? job.value?.stage === "upload"
|
||||
? "Don't disconnect the USB cable yet."
|
||||
: `A first build takes a few minutes on a Raspberry Pi. You can leave this page: the ${download.value ? "build" : "installation"} keeps running and you'll find it again under New screen.`
|
||||
: ok.value
|
||||
? download.value
|
||||
? "Put it on the screen from your own computer, then pair the screen with Home Assistant."
|
||||
: `The screen boots up and connects to your Wi-Fi.${installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : ""} Pair it with Home Assistant now:`
|
||||
: logs.value.filter((l) => /error/i.test(l)).pop() || logs.value.filter((l) => /failed/i.test(l)).pop() || "See the log below.");
|
||||
const image = computed(() => ({ href: `api/firmware/profiles/${encodeURIComponent(installer.file || "")}/download`, name: (installer.file || "").replace(/\.yaml$/, "") + ".factory.bin" }));
|
||||
const pairingSteps = computed(() => [
|
||||
["Go to Home Assistant → Settings → Devices & services.", ` This happens outside ESP Screens. Home Assistant discovers ${installer.friendly} as an ESPHome device; click Add. Not discovered? Add ESPHome manually with the screen's IP address. `],
|
||||
["Paste the API key", " above when Home Assistant asks for an encryption key."],
|
||||
["Allow HA actions:", " ESPHome integration → Configure → “Allow the device to perform Home Assistant actions”. Without this, the screen sees everything but controls nothing."],
|
||||
["Choose your tiles.", " Back in ESP Screens, the screen appears in the list on the left within about thirty seconds; until then it's shown there as “not yet in Home Assistant”."],
|
||||
]);
|
||||
async function submit(event: Event) {
|
||||
const element = event.target as HTMLFormElement;
|
||||
if (!installer.nodeEdited) form.name = slug(form.friendly_name);
|
||||
if (!element.reportValidity()) return;
|
||||
submitting.value = true;
|
||||
status.value = "";
|
||||
try {
|
||||
const payload: Record<string, string> = { board: form.board, friendly_name: form.friendly_name, name: form.name, target: form.target };
|
||||
if (askWifi.value) { if (wifiMissing.value.includes("wifi_ssid")) payload.wifi_ssid = form.wifi_ssid; if (wifiMissing.value.includes("wifi_password")) payload.wifi_password = form.wifi_password; }
|
||||
const result = await send("firmware/profiles", "POST", payload);
|
||||
Object.assign(installer, { file: result.file, apiKey: result.api_key, friendly: form.friendly_name.trim(), board: form.board, target: form.target });
|
||||
form.wifi_password = "";
|
||||
if (result.job) showProgress(result.job, []);
|
||||
else installer.view = "done";
|
||||
} catch (err: any) {
|
||||
status.value = err.message;
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function retry() {
|
||||
try {
|
||||
if (!download.value) {
|
||||
const { ports: fresh } = await getJson("firmware");
|
||||
// The board may have been replugged; a single visible port is unambiguous.
|
||||
if (!fresh.includes(installer.target) && fresh.length === 1) installer.target = fresh[0];
|
||||
}
|
||||
const next = await send("firmware/jobs", "POST", download.value
|
||||
? { file: installer.file, action: "download" }
|
||||
: { file: installer.file, action: "install", target: installer.target });
|
||||
showProgress(next, []);
|
||||
} catch (err: any) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(installer, { view: "setup", file: null, apiKey: null, nodeEdited: false, jobState: null, target: "", picked: false, action: null });
|
||||
Object.assign(form, { board: "cyd", friendly_name: "", name: "", wifi_ssid: "", wifi_password: "", target: "" });
|
||||
nodeVisible.value = false; job.value = null; logs.value = []; status.value = ""; note.value = ""; logOpen.value = false;
|
||||
installerRefresh();
|
||||
}
|
||||
function close() {
|
||||
if (installer.view === "progress" && installer.jobState !== "running") installer.view = "done";
|
||||
go("");
|
||||
}
|
||||
onMounted(() => { installerRefresh(); poll = window.setInterval(installerRefresh, 3000); });
|
||||
onBeforeUnmount(() => clearInterval(poll));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="installer">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">New screen</span>
|
||||
<h1 id="install-title">{{ installer.view === "setup" ? "Connect and install." : title }}</h1>
|
||||
<p v-if="installer.view === 'setup'">ESP Screens creates the screen's own profile with unique keys and builds the firmware. Connect the screen with a USB data cable to the machine running Home Assistant, or download the firmware and put it on the screen from your own computer.</p>
|
||||
</div>
|
||||
<button type="button" class="btn quiet" id="close-install" aria-label="Close" @click="close">← Back</button>
|
||||
</div>
|
||||
<form v-if="installer.view === 'setup'" id="install-form" class="card" @submit.prevent="submit">
|
||||
<fieldset>
|
||||
<legend>Which screen do you have?</legend>
|
||||
<div class="boards">
|
||||
<label class="board"><input type="radio" name="board" value="cyd" v-model="form.board" /><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" v-model="form.board" /><span><b>Guition · 4 inch</b><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="friendly_name">Name</label>
|
||||
<input id="friendly_name" name="friendly_name" v-model="form.friendly_name" required maxlength="60" placeholder="Living room screen" autocomplete="off" />
|
||||
<small class="node-line">Device name <code id="node-preview">{{ nodePreview }}</code><button type="button" class="btn link mini" id="edit-node" @click="installer.nodeEdited = true; nodeVisible = true">customize</button></small>
|
||||
<small>Every screen gets its own profile with its own keys. Four screens? Go through this four times with a different name.</small>
|
||||
</div>
|
||||
<div v-if="nodeVisible" class="field" id="node-label">
|
||||
<label class="f-label" for="node-name">Device name</label>
|
||||
<input id="node-name" name="name" v-model="form.name" pattern="[a-z][a-z0-9\-]{0,29}" maxlength="30" autocomplete="off" @input="installer.nodeEdited = true" />
|
||||
<small>Lowercase letters, digits, and dashes. A different name for each screen.</small>
|
||||
</div>
|
||||
<fieldset v-if="askWifi" id="wifi-fields" class="wifi">
|
||||
<legend>Wi-Fi</legend>
|
||||
<p class="hint" id="wifi-status">{{ wifiStatus }}</p>
|
||||
<div v-if="wifiMissing.includes('wifi_ssid')" class="field" id="wifi-ssid-label"><label class="f-label" for="wifi_ssid">Wi-Fi name (2.4 GHz)</label><input id="wifi_ssid" name="wifi_ssid" v-model="form.wifi_ssid" autocomplete="off" /></div>
|
||||
<div v-if="wifiMissing.includes('wifi_password')" class="field" id="wifi-password-label"><label class="f-label" for="wifi_password">Wi-Fi password</label><input id="wifi_password" name="wifi_password" type="password" v-model="form.wifi_password" autocomplete="new-password" /></div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<label class="f-label" for="install-target">Install via</label>
|
||||
<select id="install-target" name="target" v-model="form.target" @change="installer.picked = true; installerRefresh()">
|
||||
<option v-if="!ports.length" value="usb">USB · no board found on the Home Assistant machine yet</option>
|
||||
<option v-for="p in ports" :key="p" :value="p">{{ portLabel(p) }}</option>
|
||||
<option value="download">{{ DOWNLOAD_TARGET }}</option>
|
||||
<option value="">Later · save profile only</option>
|
||||
</select>
|
||||
<small id="target-hint">{{ targetHint }}</small>
|
||||
</div>
|
||||
<p v-if="note" class="hint" id="install-note">{{ note }}</p>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn primary" id="install-go" :disabled="goDisabled">{{ goLabel }}</button>
|
||||
<span id="install-status" class="status-line error" role="status">{{ status }}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else id="install-progress" class="card">
|
||||
<div class="progress-head">
|
||||
<span v-if="running" class="spin" id="progress-spin"></span>
|
||||
<span v-else class="outcome" :class="ok ? 'ok' : 'bad'" id="progress-mark">{{ ok ? "✓" : "✕" }}</span>
|
||||
<strong id="progress-title">{{ progressTitle }}</strong>
|
||||
</div>
|
||||
<p id="progress-detail">{{ progressDetail }}</p>
|
||||
<div v-if="ok && download && installer.view !== 'done'" id="install-download" class="card" style="background: var(--surface-2)">
|
||||
<a class="btn primary" id="download-firmware" :href="image.href" :download="image.name">Download {{ image.name }}</a>
|
||||
<ol class="steps" id="download-steps">
|
||||
<li><b>Plug the screen into your computer</b> with a USB data cable.</li>
|
||||
<li><b>Open <a :href="ESPHOME_WEB" target="_blank" rel="noopener">ESPHome Web</a></b> in Chrome or Edge, click Connect and choose the screen's USB port.</li>
|
||||
<li><b>Click Install</b> and select {{ image.name }}. The screen restarts and joins your Wi-Fi.{{ installer.board === "cyd" ? " The CYD first asks for a touch calibration: tap the crosshairs." : "" }}</li>
|
||||
</ol>
|
||||
<small>The file holds your Wi-Fi password and the screen's keys: keep it to yourself.</small>
|
||||
</div>
|
||||
<div v-if="ok" id="install-result" class="field">
|
||||
<div class="key-box">
|
||||
<span>API key</span><code id="api-key" ref="keyBox">{{ installer.apiKey || "" }}</code>
|
||||
<button type="button" class="btn quiet mini" id="copy-key" @click="copyText(installer.apiKey || '', keyBox)">Copy</button>
|
||||
</div>
|
||||
<ol class="steps" id="install-steps">
|
||||
<li v-for="([b, t], i) in pairingSteps" :key="i"><b>{{ b }}</b>{{ t }}<button v-if="i === 0" type="button" class="btn quiet mini" @click="openIntegrations">Open Devices & services</button></li>
|
||||
</ol>
|
||||
</div>
|
||||
<details v-if="installer.view !== 'done'" id="install-log-wrap" class="log-wrap" :open="logOpen" @toggle="logOpen = ($event.target as HTMLDetailsElement).open">
|
||||
<summary>ESPHome log</summary>
|
||||
<pre id="install-log" class="log">{{ logs.join("\n") }}</pre>
|
||||
</details>
|
||||
<div class="actions">
|
||||
<button v-if="!running && !ok" type="button" class="btn primary" id="install-retry" @click="retry">Retry</button>
|
||||
<button type="button" class="btn quiet" id="install-close" @click="reset">{{ ok ? "Install another screen" : "Start over" }}</button>
|
||||
<button type="button" class="btn" :class="ok ? 'primary' : 'quiet'" @click="close">{{ ok ? "Done" : "Close" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// The pages side by side, like swiping on the screen, and the library on the right.
|
||||
import { computed } from "vue";
|
||||
import { entriesOf, hasGaps, MAX_PAGES, pageCount } from "../model/layout";
|
||||
import { addPage, closeInspector, currentScreen, pagesShown, state, supports, tileLimit } from "../store";
|
||||
import DevicePage from "./DevicePage.vue";
|
||||
import Library from "./Library.vue";
|
||||
|
||||
const layout = computed(() => state.layout!);
|
||||
const entries = computed(() => state.drag.preview || entriesOf(layout.value));
|
||||
const pages = computed(() => pageCount(entries.value, layout.value.pages));
|
||||
const shown = computed(() => pagesShown());
|
||||
const canAdd = computed(() => pages.value < MAX_PAGES);
|
||||
const positionsHint = computed(() => hasGaps(layout.value.tiles) && !supports(0, 2, 26)
|
||||
? `Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${currentScreen.value?.firmware || "unknown"}) shifts the tiles up to the first free slot until that update.`
|
||||
: "");
|
||||
function onCanvasClick(e: MouseEvent) {
|
||||
// A click beside the pages closes the drawer; the cards and the bar handle their own clicks.
|
||||
if ((e.target as HTMLElement).closest(".device, .page-label, .canvas-head")) return;
|
||||
if (state.inspector) closeInspector();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas" id="canvas" @click="onCanvasClick">
|
||||
<div class="canvas-head">
|
||||
<b id="count">{{ pages }} {{ pages === 1 ? "page" : "pages" }} · {{ layout.tiles.length }} / {{ tileLimit }} tiles</b>
|
||||
<span v-if="!layout.tiles.length" id="no-tiles">Add your first light, scene, or device from the library.</span>
|
||||
<span v-else>Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.</span>
|
||||
<span v-if="positionsHint" id="positions-hint" class="warn">{{ positionsHint }}</span>
|
||||
</div>
|
||||
<div class="pages" id="layout-preview" aria-label="Screen layout">
|
||||
<DevicePage v-for="page in shown" :key="page" :page="page - 1" :entries="entries" :pages="pages" :moving="state.drag.moving" />
|
||||
<div class="page ghost" :class="{ disabled: !canAdd }">
|
||||
<div class="page-label"><span>Page {{ shown + 1 }}</span></div>
|
||||
<div class="device" :class="{ cyd: currentScreen?.board !== 'guition' }" id="add-page" role="button" :tabindex="canAdd ? 0 : -1" @click="canAdd && addPage()" @keydown.enter.prevent="canAdd && addPage()">
|
||||
{{ canAdd ? "+ Add page" : `${MAX_PAGES} pages is the most` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Library />
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// The entities a tile can show, with a search and one filter per domain. A click adds the entity to the
|
||||
// marked empty cell or the first free one; a drag puts it exactly where it lands.
|
||||
import { computed } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, isGuition, state, tileLimit } from "../store";
|
||||
|
||||
const FILTERS: [string, string][] = [
|
||||
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
|
||||
["script", "Scripts"], ["fan", "Fans"], ["cover", "Covers"], ["scene", "Scenes"], ["vacuum", "Vacuum"], ["sensor", "Sensors"],
|
||||
["media_player", "Media"], ["weather", "Weather"], ["number", "Values"], ["select", "Selects"], ["person", "People"], ["timer", "Timers"], ["screen", "Clock"],
|
||||
];
|
||||
const ALIAS: Record<string, string> = { switch: "input_boolean", number: "input_number", select: "input_select", weather: "sun", button: "input_button" };
|
||||
const chosen = computed(() => new Set(state.layout?.tiles.map((t) => t.entity) || []));
|
||||
const matches = computed(() => {
|
||||
const query = state.search.toLocaleLowerCase(), filter = state.filter;
|
||||
// The picker offers what a tile can show; camera images need a Guition (app 0.2.66).
|
||||
return [...(state.inventory.builtin || []), ...state.inventory.entities].filter((e) =>
|
||||
e.tile !== false &&
|
||||
(isGuition.value || !["camera", "image"].includes(e.id.split(".")[0])) &&
|
||||
(!filter || e.id.startsWith(filter + ".") || ALIAS[filter] === e.id.split(".")[0]) &&
|
||||
`${e.name} ${e.id} ${e.device || ""} ${e.area || ""}`.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const full = computed(() => (state.layout?.tiles.length || 0) >= tileLimit.value);
|
||||
const count = computed(() => state.inventory.entities.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="library" id="library">
|
||||
<div class="lib-head">
|
||||
<div class="lib-title">Library <small>{{ count }} entities</small></div>
|
||||
<input id="search" v-model="state.search" type="search" placeholder="Search by name, device, or room…" autocomplete="off" aria-label="Add an entity" />
|
||||
<div class="filters" id="filters">
|
||||
<button v-for="[value, label] in FILTERS" :key="value" type="button" :aria-pressed="state.filter === value ? 'true' : 'false'" @click="state.filter = value">
|
||||
<span v-if="value" class="domain-icon" :style="{ color: domainInfo(value + '.')[2], background: domainInfo(value + '.')[3] }" aria-hidden="true">{{ domainInfo(value + ".")[1] }}</span>{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-list" id="results" aria-live="polite">
|
||||
<button v-for="entity in matches.slice(0, 80)" :key="entity.id" type="button" class="ent" :title="entity.id"
|
||||
:disabled="chosen.has(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
|
||||
<span class="av mdi" :style="{ color: domainInfo(entity.id)[2], background: domainInfo(entity.id)[3] }">{{ state.inventory.icons ? glyph(automaticIcon(entity.id)) : domainInfo(entity.id)[1] }}</span>
|
||||
<span class="tx">
|
||||
<b>{{ entity.name }}</b>
|
||||
<small>{{ [domainInfo(entity.id)[0], entity.area, entity.device].filter(Boolean).join(" · ") }}</small>
|
||||
</span>
|
||||
<span class="add" :class="{ done: chosen.has(entity.id) }">{{ chosen.has(entity.id) ? "✓" : "+" }}</span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">No entities found. Try a different name.</p>
|
||||
<p v-else-if="matches.length > 80" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
</div>
|
||||
<div class="lib-foot">{{ full ? `This screen holds ${tileLimit} tiles${tileLimit === 10 ? "; update its firmware for 20" : ""}.` : "Drag onto a page, or tap + to add to the first free slot." }}</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
// Per-screen local YAML override: a small file of the owner's, loaded after the shared screen package.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { getJson, send } from "../api";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { go, state, toast } from "../store";
|
||||
|
||||
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 profile = computed(() => state.overrideProfile);
|
||||
const content = ref("");
|
||||
const file = ref("");
|
||||
const attached = ref(false);
|
||||
const status = ref("Loading…");
|
||||
const kind = ref("");
|
||||
const busy = ref(false);
|
||||
const editor = ref<HTMLTextAreaElement | null>(null);
|
||||
const gutter = ref<HTMLDivElement | null>(null);
|
||||
const lines = computed(() => content.value.split("\n").length);
|
||||
const gutterText = computed(() => Array.from({ length: lines.value }, (_, i) => i + 1).join("\n"));
|
||||
const count = computed(() => `${content.value.length} characters · ${lines.value} line${lines.value === 1 ? "" : "s"}`);
|
||||
let alive = true;
|
||||
function setStatus(message: string, k = "") { status.value = message; kind.value = k; }
|
||||
async function load() {
|
||||
if (!profile.value) { setStatus("No ESPHome profile was found for this screen.", "error"); return; }
|
||||
setStatus("Loading…");
|
||||
try {
|
||||
const data = await getJson(`firmware/profiles/${encodeURIComponent(profile.value)}/override`);
|
||||
file.value = data.override_file;
|
||||
attached.value = Boolean(data.attached);
|
||||
content.value = data.exists && data.content !== "{}\n" ? data.content : "";
|
||||
setStatus(data.attached ? "Changes here apply on the next build." : "Save once to attach this file to the profile.");
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
}
|
||||
async function saveOverride(runCheck = false) {
|
||||
if (!profile.value || busy.value) return;
|
||||
busy.value = true;
|
||||
setStatus("Checking and saving…");
|
||||
try {
|
||||
const data = await send(`firmware/profiles/${encodeURIComponent(profile.value)}/override`, "PUT", { content: content.value });
|
||||
file.value = data.override_file;
|
||||
attached.value = true;
|
||||
if (!runCheck) {
|
||||
setStatus("Saved. The override is kept during firmware updates.", "ok");
|
||||
toast("Override YAML saved.");
|
||||
return;
|
||||
}
|
||||
setStatus("Saved. ESPHome is checking the complete profile…");
|
||||
await send("firmware/jobs", "POST", { file: profile.value, action: "validate" });
|
||||
pollCheck();
|
||||
} catch (error: any) {
|
||||
setStatus(error.message, "error");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
function pollCheck() {
|
||||
const started = Date.now(), checked = profile.value;
|
||||
const poll = async () => {
|
||||
if (!alive || profile.value !== checked) return; // the page closed or shows another screen
|
||||
try {
|
||||
const data = await getJson("firmware");
|
||||
const current = data.job;
|
||||
if (current && current.file === checked && current.state === "running") {
|
||||
setStatus(`ESPHome is checking the profile… ${current.stage || ""}`.trim());
|
||||
} else if (current && current.file === checked && current.state === "success") {
|
||||
setStatus("The complete profile is valid. Safe to build and install.", "ok");
|
||||
return;
|
||||
} else if (current && current.file === checked && current.state === "failed") {
|
||||
const error = (data.logs || []).filter((line: string) => /error|failed/i.test(line)).pop();
|
||||
setStatus(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: any) {
|
||||
setStatus(error.message, "error");
|
||||
}
|
||||
};
|
||||
poll();
|
||||
}
|
||||
function useExample() {
|
||||
if (!content.value.trim() || confirm("Replace the current text with the example?")) {
|
||||
content.value = OVERRIDE_EXAMPLE;
|
||||
setStatus("Example loaded. Save it when you are ready.");
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
function clear() {
|
||||
if (!content.value.trim() || confirm("Clear the local override?")) {
|
||||
content.value = "";
|
||||
setStatus("The override will be empty after you save.");
|
||||
editor.value?.focus();
|
||||
}
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
const area = editor.value!;
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const start = area.selectionStart, end = area.selectionEnd;
|
||||
area.setRangeText(" ", start, end, "end");
|
||||
content.value = area.value;
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
saveOverride(false);
|
||||
}
|
||||
}
|
||||
function syncScroll() { if (gutter.value && editor.value) gutter.value.scrollTop = editor.value.scrollTop; }
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => { alive = false; });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel" id="override-dialog">
|
||||
<div class="panel-head">
|
||||
<div class="tx">
|
||||
<span class="eyebrow">Advanced · local override</span>
|
||||
<h1 id="override-title">Override YAML{{ state.overrideFriendly ? ` · ${state.overrideFriendly}` : "" }}</h1>
|
||||
<p>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>
|
||||
<button type="button" class="btn quiet" id="close-override" @click="go('')">← Back</button>
|
||||
</div>
|
||||
<div class="notice">
|
||||
<span class="mdi">{{ glyph("F0493") }}</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="file-row">
|
||||
<code id="override-file">{{ file || profile || "" }}</code>
|
||||
<span id="override-state" class="chip" :class="{ good: attached }">{{ attached ? "Active" : "Ready to attach" }}</span>
|
||||
</div>
|
||||
<div class="yaml-editor" id="yaml-editor-wrap">
|
||||
<div ref="gutter" class="yaml-gutter" id="override-gutter" aria-hidden="true">{{ gutterText }}</div>
|
||||
<textarea ref="editor" id="override-editor" v-model="content" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off"
|
||||
aria-label="Local override YAML" placeholder="# Example display: - id: !extend my_display model: ST7789V" @keydown="onKey" @scroll="syncScroll"></textarea>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet mini" id="override-example" @click="useExample">Use example</button>
|
||||
<button type="button" class="btn quiet mini" id="override-empty" @click="clear">Clear</button>
|
||||
<span id="override-count" class="hint">{{ count }}</span>
|
||||
</div>
|
||||
<p id="override-status" class="status-line" :class="kind" role="status">{{ status }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn quiet" id="override-check" :disabled="busy || !profile" @click="saveOverride(true)">Save & check</button>
|
||||
<button type="button" class="btn primary" id="override-save" :disabled="busy || !profile" @click="saveOverride(false)">Save override</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
// One screen: the head with its status, the Layout and Settings tabs, and the drawer over the right side.
|
||||
import { computed, onBeforeUnmount, onMounted } from "vue";
|
||||
import { closeInspector, currentScreen, go, save, state } from "../store";
|
||||
import LayoutView from "./LayoutView.vue";
|
||||
import SettingsTab from "./SettingsTab.vue";
|
||||
import Drawer from "./Drawer.vue";
|
||||
|
||||
const screen = computed(() => currentScreen.value!);
|
||||
const statusText = computed(() => screen.value.online
|
||||
? `${screen.value.delivery} · ${screen.value.status}`
|
||||
: "Offline · changes are saved");
|
||||
const updateReady = computed(() => screen.value.update?.available && screen.value.online && screen.value.update?.profile);
|
||||
function openOverride() {
|
||||
state.menuOpen = false;
|
||||
state.overrideProfile = screen.value.update?.profile || null;
|
||||
state.overrideFriendly = screen.value.name;
|
||||
go("#override");
|
||||
}
|
||||
function inspectAll() {
|
||||
state.menuOpen = false;
|
||||
state.selectedTile = null;
|
||||
state.inspector = { kind: "inspect" };
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (state.menuOpen) state.menuOpen = false;
|
||||
else if (state.inspector && !(e.target as HTMLElement)?.closest?.(".picker")) closeInspector();
|
||||
}
|
||||
}
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (state.menuOpen && !(e.target as HTMLElement).closest(".head-right .menu, #more")) state.menuOpen = false;
|
||||
}
|
||||
onMounted(() => { document.addEventListener("keydown", onKey); document.addEventListener("click", onDocClick); });
|
||||
onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document.removeEventListener("click", onDocClick); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="main-head">
|
||||
<div>
|
||||
<span class="eyebrow" id="screen-name">{{ screen.name }}</span>
|
||||
<h1>{{ state.layout?.title || "Home" }}</h1>
|
||||
</div>
|
||||
<span id="delivery" class="chip" :class="{ off: !screen.online }" :title="statusText"><span class="dot"></span>{{ statusText }}</span>
|
||||
<div class="head-right">
|
||||
<div class="seg" role="tablist">
|
||||
<button type="button" id="tab-layout" role="tab" :aria-pressed="state.tab === 'layout' ? 'true' : 'false'" @click="state.tab = 'layout'">Layout</button>
|
||||
<button type="button" id="tab-settings" role="tab" :aria-pressed="state.tab === 'settings' ? 'true' : 'false'" @click="state.tab = 'settings'; closeInspector()">Screen settings</button>
|
||||
</div>
|
||||
<span v-if="!state.dirty" id="dirty" class="chip" :class="{ sent: state.saved }">{{ state.saved ? "Saved · sent to screen" : "All saved" }}</span>
|
||||
<span v-else id="dirty" class="chip dirty">Unsaved changes</span>
|
||||
<button v-if="state.dirty" id="save" type="button" class="btn primary" :disabled="state.busy" @click="save()">
|
||||
<span v-if="state.busy" class="spin small"></span>{{ state.busy ? "Saving…" : "Save & send" }}
|
||||
</button>
|
||||
<button id="more" type="button" class="icon-btn" aria-label="More" aria-haspopup="menu" :aria-expanded="state.menuOpen ? 'true' : 'false'" @click.stop="state.menuOpen = !state.menuOpen">···</button>
|
||||
<div v-if="state.menuOpen" class="menu" role="menu">
|
||||
<button type="button" role="menuitem" id="inspect" @click="inspectAll">Read current data <small>from Home Assistant</small></button>
|
||||
<button type="button" role="menuitem" id="open-override" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found for this screen'" @click="openOverride">Override YAML <small>advanced</small></button>
|
||||
<button type="button" role="menuitem" @click="state.menuOpen = false; go('#firmware')">Firmware & USB</button>
|
||||
<button v-if="updateReady" type="button" role="menuitem" @click="state.menuOpen = false; $emit('update')">Update firmware <small>{{ screen.update?.target }}</small></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body" id="body">
|
||||
<LayoutView v-if="state.tab === 'layout'" />
|
||||
<SettingsTab v-else />
|
||||
<Drawer />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ choices: readonly (readonly [unknown, string])[]; value: unknown; disabled?: boolean }>();
|
||||
const emit = defineEmits<{ (e: "pick", value: any): void }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seg wrap" role="group">
|
||||
<button v-for="[key, text] in choices" :key="String(key)" type="button" :disabled="disabled"
|
||||
:aria-pressed="String(key) === String(value) ? 'true' : 'false'" @click="emit('pick', key)">{{ text }}</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
// Screen settings: the same groups and rows as the settings page on the screen itself. Every change applies at
|
||||
// once, like on the screen; no Save needed.
|
||||
import { computed } from "vue";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { currentScreen, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
|
||||
|
||||
const view = computed(() => settingsView());
|
||||
const values = computed(() => settingValues());
|
||||
const offline = computed(() => view.value?.owner === "screen" && !currentScreen.value?.online);
|
||||
const groups = computed(() => SETTING_GROUPS.map((group) => ({ ...group, rows: (group.rows as readonly SettingRow[]).filter((row) => view.value?.keys.includes(row.key)) })).filter((g) => g.rows.length));
|
||||
const unavailable = (row: SettingRow) => offline.value || Boolean(view.value?.unavailable.includes(row.key));
|
||||
const needs = (row: SettingRow) => (row.needs ? Boolean(values.value[row.needs]) : true);
|
||||
const status = computed(() => offline.value
|
||||
? "This screen is offline. Its settings can change once it's back."
|
||||
: state.settingPending
|
||||
? "Saving…"
|
||||
: view.value?.owner === "screen"
|
||||
? "Changes apply on the screen at once, and show up here when they change there."
|
||||
: "Changes apply at once. Firmware 0.2.49 lets the screen keep them itself.");
|
||||
const stepDisabled = (row: SettingRow, direction: number) => {
|
||||
const value = values.value[row.key];
|
||||
if (unavailable(row) || !needs(row) || value === null || value === undefined) return true;
|
||||
return row.kind !== "moment" && steppedSetting(row, value, direction, false, values.value) === value;
|
||||
};
|
||||
// A key held down steps again and again, faster after a moment, like the -/+ keys on the screen.
|
||||
const holds = new WeakMap<HTMLElement, { timer: number; held: boolean }>();
|
||||
function step(row: SettingRow, direction: number, held: boolean) {
|
||||
const v = settingValues();
|
||||
const next = steppedSetting(row, v[row.key], direction, held, v);
|
||||
if (next !== v[row.key]) setSetting(row.key, next, 600);
|
||||
}
|
||||
function down(e: PointerEvent, row: SettingRow, direction: number) {
|
||||
const button = e.currentTarget as HTMLButtonElement;
|
||||
if (button.disabled || e.button !== 0) return;
|
||||
const hold = { timer: 0, held: false };
|
||||
holds.set(button, hold);
|
||||
let repeats = 0;
|
||||
hold.timer = window.setTimeout(function repeat() {
|
||||
hold.held = true;
|
||||
repeats += 1;
|
||||
step(row, direction, repeats > 5);
|
||||
hold.timer = window.setTimeout(repeat, 180);
|
||||
}, 450);
|
||||
}
|
||||
function up(e: PointerEvent) {
|
||||
const hold = holds.get(e.currentTarget as HTMLElement);
|
||||
if (hold) clearTimeout(hold.timer);
|
||||
}
|
||||
function click(e: MouseEvent, row: SettingRow, direction: number) {
|
||||
const hold = holds.get(e.currentTarget as HTMLElement);
|
||||
if (hold?.held) { hold.held = false; return; }
|
||||
step(row, direction, false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings" id="general-settings" :class="{ offline }">
|
||||
<span class="status" id="settings-status" role="status">{{ status }}</span>
|
||||
<div v-if="view" class="set-grid" id="settings-groups">
|
||||
<section v-for="group in groups" :key="group.title" class="set-card">
|
||||
<h4><span class="mdi">{{ glyph(group.icon) }}</span>{{ group.title }}</h4>
|
||||
<div v-for="row in group.rows" :key="row.key" class="srow" :class="[`setting-${row.kind}`, { inactive: !needs(row) || unavailable(row) }]" :data-setting="row.key"
|
||||
:title="unavailable(row) && !offline ? 'This entity is off in Home Assistant, or the screen is restarting.' : ''"
|
||||
@click="row.kind === 'toggle' && ($event.target as HTMLElement).closest('.srow') === $event.currentTarget && !($event.target as HTMLElement).closest('button') && !unavailable(row) && setSetting(row.key, !values[row.key], 150)">
|
||||
<span class="s-label" :id="`setting-label-${row.key}`">{{ row.label }}</span>
|
||||
<div class="s-control">
|
||||
<button v-if="row.kind === 'toggle'" type="button" class="switch" :class="{ unknown: values[row.key] === null || values[row.key] === undefined }" role="switch"
|
||||
:id="`setting-${row.key}`" :aria-checked="Boolean(values[row.key]) ? 'true' : 'false'" :aria-labelledby="`setting-label-${row.key}`"
|
||||
:disabled="unavailable(row)" @click.stop="setSetting(row.key, !values[row.key], 150)"></button>
|
||||
<div v-else-if="row.kind === 'choice'" class="seg" role="group" :aria-labelledby="`setting-label-${row.key}`">
|
||||
<button v-for="[value, text] in row.options!" :key="String(value)" type="button" :aria-pressed="values[row.key] === value ? 'true' : 'false'" :disabled="unavailable(row)" @click="setSetting(row.key, value, 150)">{{ text }}</button>
|
||||
</div>
|
||||
<div v-else class="step">
|
||||
<button type="button" :aria-label="`${row.label} lower`" :disabled="stepDisabled(row, -1)" @pointerdown="down($event, row, -1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, -1)"><span class="mdi">{{ glyph("F0374") }}</span></button>
|
||||
<output :id="`setting-${row.key}`" :aria-labelledby="`setting-label-${row.key}`">{{ settingText(row, values) }}</output>
|
||||
<button type="button" :aria-label="`${row.label} higher`" :disabled="stepDisabled(row, 1)" @pointerdown="down($event, row, 1)" @pointerup="up" @pointercancel="up" @pointerleave="up" @click="click($event, row, 1)"><span class="mdi">{{ glyph("F0415") }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, openIntegrations, PHASES, refresh, route, select, startUpdate, state } from "../store";
|
||||
import type { Screen } from "../types";
|
||||
|
||||
const hostFor = ref<string | null>(null);
|
||||
const host = ref("");
|
||||
function updateState(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.state === "running" || state.updating.includes(screen.id)) return { kind: "running", text: PHASES[u.phase || ""] || "Starting update…" };
|
||||
if (u.state === "queued") return { kind: "queued", text: "Queued for the update" };
|
||||
if (u.available && screen.online) return { kind: "available", text: `Update ${u.target}` };
|
||||
if (u.result && Date.now() / 1000 - u.result.time < 86400) return { kind: u.result.state === "failed" ? "failed" : "done", text: u.result.message };
|
||||
return null;
|
||||
}
|
||||
function update(screen: Screen) {
|
||||
const u = screen.update || {};
|
||||
if (u.host && u.profile) startUpdate(screen);
|
||||
else if (u.profile) { hostFor.value = screen.id; host.value = ""; }
|
||||
}
|
||||
function startWithHost(screen: Screen) {
|
||||
const address = host.value.trim();
|
||||
if (!address) return;
|
||||
hostFor.value = null;
|
||||
startUpdate(screen, address);
|
||||
}
|
||||
const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: string }) => p.installed
|
||||
? "Installed, but not yet in Home Assistant. Add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and turn on “Allow the device to perform Home Assistant actions” under Configure."
|
||||
: p.downloaded
|
||||
? "Firmware downloaded, not yet in Home Assistant. Put it on the screen with ESPHome Web, then add the discovered ESPHome device under Settings → Devices & services, paste the API key there, and allow the Home Assistant actions under Configure."
|
||||
: `Not yet in Home Assistant. Already flashed? Add the ESPHome device under Settings → Devices & services, then allow the Home Assistant actions under Configure. Not flashed yet? Firmware & USB → ${p.file}.`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="side">
|
||||
<div class="brand"><span class="mark">▦</span><span>ESP Screens</span></div>
|
||||
<span id="connection" class="conn" :class="{ online: state.connected }" role="status">
|
||||
{{ !state.reachable ? "Management page unreachable · retrying…" : state.connected ? "Home Assistant connected" : "Reconnecting to Home Assistant…" }}
|
||||
</span>
|
||||
<div class="label">Screens</div>
|
||||
<div id="screens">
|
||||
<div v-for="screen in state.inventory.screens" :key="screen.id" class="screen-item" :class="{ selected: screen.id === state.selected && route === '' }">
|
||||
<button type="button" class="nav-item" :aria-current="screen.id === state.selected && route === '' ? 'true' : 'false'" @click="select(screen.id)">
|
||||
<span class="dot" :class="{ off: !screen.online }"></span>
|
||||
<span class="txt">
|
||||
<span>{{ screen.name }}</span>
|
||||
<small>{{ screen.online ? "Online" : "Offline" }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || "unknown" }}</small>
|
||||
</span>
|
||||
<span v-if="updateState(screen)?.kind === 'available'" class="pill">Update</span>
|
||||
<span v-else-if="updateState(screen)?.kind === 'running'" class="spin small"></span>
|
||||
</button>
|
||||
<div v-if="updateState(screen)" class="screen-update" :class="updateState(screen)!.kind">
|
||||
<template v-if="updateState(screen)!.kind === 'available'">
|
||||
<template v-if="hostFor === screen.id">
|
||||
<small>The IP address, once; new firmware reports it itself.</small>
|
||||
<form class="screen-host" @submit.prevent="startWithHost(screen)">
|
||||
<input v-model="host" placeholder="IP address, e.g. 192.168.1.50" required pattern="[A-Za-z0-9][A-Za-z0-9.\-]*" aria-label="IP address" autofocus />
|
||||
<button type="submit" class="btn mini primary">Start</button>
|
||||
<button type="button" class="icon-btn" aria-label="Cancel" @click="hostFor = null">✕</button>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<small>{{ updateState(screen)!.text }}</small>
|
||||
<button type="button" class="btn mini primary" :disabled="!screen.update?.profile" :title="screen.update?.profile ? '' : 'No ESPHome profile found with this device name.'" @click="update(screen)">Update</button>
|
||||
</template>
|
||||
</template>
|
||||
<small v-else :class="{ failed: updateState(screen)!.kind === 'failed' }">{{ updateState(screen)!.text }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending">
|
||||
<div v-for="p in state.inventory.pending || []" :key="p.file" class="pending">
|
||||
<strong>{{ p.friendly }}</strong>
|
||||
<small>{{ pendingText(p) }}</small>
|
||||
<div class="pending-actions">
|
||||
<button type="button" class="btn mini quiet" @click="openIntegrations">Open Devices & services</button>
|
||||
<button v-if="p.api_key" type="button" class="btn mini quiet" @click="copyText(p.api_key!)">Copy API key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="new-screen" type="button" class="nav-item ghost" :aria-current="route === '#new-screen' ? 'true' : 'false'" @click="go('#new-screen')">
|
||||
<span class="plus">+</span><span class="txt">New screen</span>
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="more">
|
||||
<div class="label">More</div>
|
||||
<button id="open-firmware" type="button" class="nav-item" :aria-current="route === '#firmware' ? 'true' : 'false'" @click="go('#firmware')"><span class="mdi">{{ glyph("F0241") }}</span><span class="txt">Firmware & USB</span></button>
|
||||
<button id="open-alerts" type="button" class="nav-item" :aria-current="route === '#alerts' ? 'true' : 'false'" @click="go('#alerts')"><span class="mdi">{{ glyph("F0594") }}</span><span class="txt">Alerts</span></button>
|
||||
<button id="open-settings" type="button" class="nav-item" :aria-current="route === '#settings' ? 'true' : 'false'" @click="go('#settings')"><span class="mdi">{{ glyph("F0493") }}</span><span class="txt">Settings</span></button>
|
||||
<button id="refresh" type="button" class="nav-item ghost" @click="refresh()"><span class="plus">↻</span><span class="txt">Refresh</span></button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// A card on the mockup. A placeholder is the tile being dragged, drawn where it will land.
|
||||
import { computed, nextTick } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { displayNames, effectiveControls, isWide, pageOf, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { entityName, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
const name = computed(() => props.tile.name || entityName(props.tile.entity));
|
||||
const wide = computed(() => isWide(props.tile));
|
||||
const background = computed(() => state.inventory.backgrounds?.[props.tile.options?.background || ""]?.color);
|
||||
const bare = computed(() => props.tile.options?.background === "none");
|
||||
const display = computed(() => props.tile.options?.display || "standard");
|
||||
const note = computed(() => (display.value !== "standard" ? displayNames[display.value] || display.value : ""));
|
||||
const controls = computed(() => effectiveControls(props.tile, state.inventory));
|
||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const cp = computed(() => state.inventory.icons?.controls || {});
|
||||
const key = (n: string) => (cp.value[n] ? glyph(cp.value[n]) : "");
|
||||
const chosen = computed(() => state.selectedTile === props.tile.entity && state.inspector?.kind === "tile");
|
||||
const live = computed(() => !props.placeholder && state.layout?.tiles.includes(props.tile));
|
||||
const label = computed(() => `${name.value}, slot ${(props.slot % SLOTS_PER_PAGE) + 1} on page ${pageOf(props.slot) + 1}. Enter: configure, arrow keys: move`);
|
||||
const now = computed(() => new Date(state.now));
|
||||
const hourAngle = computed(() => (now.value.getHours() % 12 + now.value.getMinutes() / 60) * 30);
|
||||
const minuteAngle = computed(() => now.value.getMinutes() * 6);
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile.entity); return; }
|
||||
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
// A wide card owns its row: left and right mean the row above and below.
|
||||
if (placeTile(props.tile, props.tile.slot + (wide.value ? Math.sign(step) * 2 : step))) {
|
||||
await nextTick();
|
||||
document.querySelector<HTMLElement>(`.pages [data-slot="${props.tile.slot}"]`)?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tile" :class="{ wide, bare, placeholder: placeholder || !live, chosen }" :data-slot="slot"
|
||||
:style="background && !bare ? { backgroundColor: background } : undefined"
|
||||
:tabindex="live ? 0 : -1" :role="live ? 'button' : undefined" :aria-label="live ? label : undefined"
|
||||
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile.entity)" @keydown="live && onKey($event)">
|
||||
<template v-if="display === 'analog'">
|
||||
<svg class="clockface" viewBox="0 0 60 60" aria-hidden="true">
|
||||
<circle cx="30" cy="30" r="27" fill="#fff" stroke="#c9ccd1" />
|
||||
<line v-for="a in [0, 90, 180, 270]" :key="a" x1="30" y1="5" x2="30" y2="9" stroke="#1b1b1b" stroke-width="1.5" :transform="`rotate(${a} 30 30)`" />
|
||||
<line x1="30" y1="30" x2="30" y2="16" stroke="#1b1b1b" stroke-width="2.4" stroke-linecap="round" :transform="`rotate(${hourAngle} 30 30)`" />
|
||||
<line x1="30" y1="30" x2="30" y2="11" stroke="#1b1b1b" stroke-width="1.6" stroke-linecap="round" :transform="`rotate(${minuteAngle} 30 30)`" />
|
||||
<circle cx="30" cy="30" r="1.8" fill="#1b1b1b" />
|
||||
</svg>
|
||||
<span v-if="wide" class="lead"><span class="tx"><span class="nm">{{ name }}</span><span class="st">{{ note }}</span></span></span>
|
||||
</template>
|
||||
<template v-else-if="wide">
|
||||
<span class="lead">
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx"><span class="nm">{{ name }}</span><span v-if="note" class="st">{{ note }}</span></span>
|
||||
</span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
|
||||
<span v-if="controls" class="ctl">
|
||||
<span v-if="controls === 'toggle'" class="tog"></span>
|
||||
<span v-else-if="controls === 'setpoint'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>20°</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
|
||||
<template v-else-if="controls === 'stepper' && domain.endsWith('select')"><span class="key mdi">{{ key("chevron-left") }}</span><span class="key mdi">{{ key("chevron-right") }}</span></template>
|
||||
<span v-else-if="controls === 'stepper'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>50</b><span class="mdi">{{ key("plus") || "+" }}</span></span>
|
||||
<template v-else-if="controls === 'mode'"><span class="key mdi">{{ key("power") }}</span><span class="key mdi">{{ key("fire") }}</span><span class="key mdi">{{ key("snowflake") }}</span></template>
|
||||
<template v-else-if="controls === 'volume'"><span class="range"></span><span class="key mdi">{{ key("volume-high") }}</span></template>
|
||||
<template v-else-if="controls === 'playback'"><span class="key mdi">{{ key("skip-previous") }}</span><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("skip-next") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'cover'"><span class="key mdi">{{ key("arrow-expand-horizontal") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("arrow-collapse-horizontal") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'vacuum'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("stop") }}</span><span class="key mdi">{{ key("home-map-marker") }}</span></template>
|
||||
<template v-else-if="controls === 'buttons' && domain === 'timer'"><span class="key mdi">{{ key("play") }}</span><span class="key mdi">{{ key("close") }}</span></template>
|
||||
<span v-else-if="controls === 'run'" class="run">{{ ({ scene: "Activate", script: "Run" } as Record<string, string>)[domain] || "Press" }}</span>
|
||||
<span v-else class="range"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="note" class="st">{{ note }}</span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
|
||||
</span>
|
||||
</template>
|
||||
<button v-if="live" type="button" class="remove" title="Remove tile" :aria-label="`Remove ${name}`" @click.stop="removeTile(tile)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
// One tile's settings. Every change applies live, so the card on the mockup shows the result while you pick.
|
||||
import { computed } from "vue";
|
||||
import { domainInfo, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { automaticIcon, closeInspector, entityName, markDirty, removeTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
import ActionPicker from "./ActionPicker.vue";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
import Segmented from "./Segmented.vue";
|
||||
|
||||
const props = defineProps<{ tile: Tile }>();
|
||||
const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const name = computed(() => entityName(props.tile.entity));
|
||||
const caps = computed(() => state.capabilities[props.tile.entity]);
|
||||
const current = (key: string, fallback: unknown) => props.tile.options?.[key] ?? fallback;
|
||||
const display = computed(() => current("display", domain.value === "screen" ? "digital" : "standard") as string);
|
||||
const displays = computed(() => {
|
||||
const list: [string, string][] = domain.value === "screen"
|
||||
? [["digital", "Digital clock"], ["analog", "Analog clock"]]
|
||||
: [["standard", "Name and status"], ["watch", "Large value"]];
|
||||
const c = caps.value;
|
||||
if (domain.value === "weather" && (!c || c.displays.includes("forecast") || display.value === "forecast")) list.push(["forecast", "Weather forecast"]);
|
||||
if (domain.value === "sensor" && (!c || c.displays.includes("graph") || display.value === "graph")) list.push(["graph", "Graph"]);
|
||||
if (domain.value === "sun") list.push(["sunpath", "Sun path"]);
|
||||
return list;
|
||||
});
|
||||
const displayHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && display.value === "graph" && !c.displays.includes("graph")) return "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.";
|
||||
if (c && display.value === "forecast" && !c.displays.includes("forecast")) return "This weather service has no daily forecast in Home Assistant. Choose another display.";
|
||||
return "";
|
||||
});
|
||||
const size = computed(() => current("size", "single") as string);
|
||||
const catalogue = computed(() => state.inventory.controls?.[domain.value]);
|
||||
const controls = computed(() => current("controls", catalogue.value?.default) as string);
|
||||
const controlChoices = computed(() => {
|
||||
const c = caps.value;
|
||||
return (catalogue.value?.choices || []).filter((ch) => !c || ch.key === "none" || ch.key === controls.value || c.controls.includes(ch.key)).map((ch) => [ch.key, ch.label] as [string, string]);
|
||||
});
|
||||
const controlHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && controls.value !== "none" && !c.controls.includes(controls.value)) return { text: "Home Assistant doesn't offer this control for this entity, so it stays empty on the screen. Choose another one.", warn: true };
|
||||
return { text: supports(0, 2, 19)
|
||||
? "On the right of the double-width tile, like the rows in Home Assistant. Tapping the name works as set below."
|
||||
: "The screen shows direct control from firmware 0.2.19; until then the tile stays as it was.", warn: false };
|
||||
});
|
||||
const tap = computed(() => current("tap", "auto") as string);
|
||||
const taps = computed(() => {
|
||||
const list: [string, string][] = [["auto", "Automatic"], ["detail", "Open control"], ["none", "View only"]];
|
||||
// On / off where Home Assistant can toggle the entity, such as a cover; a speaker without on and off gets none.
|
||||
if ((caps.value ? caps.value.toggle : TOGGLE_BEFORE.includes(domain.value)) || tap.value === "toggle") list.push(["toggle", "On / off"]);
|
||||
list.push(["action", "Perform action"]);
|
||||
return list;
|
||||
});
|
||||
const tapHint = computed(() => {
|
||||
if (tap.value === "toggle" && caps.value && !caps.value.toggle) return { text: "Home Assistant can't turn this on and off, so a tap does nothing. Choose another option.", warn: true };
|
||||
if (tap.value === "toggle" && !TOGGLE_BEFORE.includes(domain.value) && !supports(0, 2, 58)) return { text: "The screen switches this from firmware 0.2.58: press Update on the screen. Until then a tap opens its card.", warn: false };
|
||||
if (tap.value === "toggle") return { text: "Hold the tile to open its card.", warn: false };
|
||||
return null;
|
||||
});
|
||||
const inline = computed(() => current("inline", "none") as string);
|
||||
const showSlider = computed(() => SLIDER_DOMAINS.includes(domain.value) && (!caps.value || caps.value.inline || inline.value === "slider"));
|
||||
const sliderWarn = computed(() => inline.value === "slider" && caps.value && !caps.value.inline);
|
||||
const history = computed(() => current("history_hours", 24) as number);
|
||||
const backgrounds = computed(() => Object.entries(state.inventory.backgrounds || {}));
|
||||
const fromHA = computed(() => Boolean(state.inventory.entities.find((e) => e.id === props.tile.entity)?.icon));
|
||||
const showIcon = computed(() => Boolean(state.inventory.icons) && domain.value !== "screen" && !["forecast", "sunpath"].includes(display.value));
|
||||
function rename(value: string) {
|
||||
props.tile.name = value;
|
||||
markDirty();
|
||||
}
|
||||
function inspect() {
|
||||
state.inspector = { kind: "inspect", entity: props.tile.entity };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av mdi" :style="{ color: domainInfo(tile.entity)[2], background: domainInfo(tile.entity)[3] }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx"><b>{{ tile.name || name }}</b><small class="mono">{{ tile.entity }}</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="tile-name">Name on the screen</label>
|
||||
<input id="tile-name" :value="tile.name" :placeholder="name" maxlength="60" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<IconPicker v-if="showIcon" :selected="tile.options?.icon || 'auto'" :automatic="automaticIcon(tile.entity)"
|
||||
:auto-label="`Automatic (${fromHA ? 'from Home Assistant' : 'default'})`"
|
||||
:note="supports(0, 2, 18) ? '' : 'The screen shows a chosen icon from firmware 0.2.18.'"
|
||||
@pick="(n) => setTileOption(tile, 'icon', n)" />
|
||||
<div class="f">
|
||||
<span class="f-label">Display</span>
|
||||
<Segmented :choices="displays" :value="display" @pick="(v) => setTileOption(tile, 'display', v)" />
|
||||
<small v-if="displayHint" class="warn">{{ displayHint }}</small>
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Width</span>
|
||||
<Segmented :choices="[['single', 'Normal'], ['wide', 'Double-width']]" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
||||
</div>
|
||||
<div v-if="catalogue && size === 'wide'" class="f">
|
||||
<span class="f-label">Direct control on the tile</span>
|
||||
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
|
||||
<small :class="{ warn: controlHint.warn }">{{ controlHint.text }}</small>
|
||||
</div>
|
||||
<div v-if="domain !== 'screen'" class="f">
|
||||
<span class="f-label">On tap</span>
|
||||
<Segmented :choices="taps" :value="tap" @pick="(v) => setTileOption(tile, 'tap', v)" />
|
||||
<small v-if="tapHint" :class="{ warn: tapHint.warn }">{{ tapHint.text }}</small>
|
||||
</div>
|
||||
<ActionPicker v-if="domain !== 'screen' && tap === 'action'" :tile="tile" />
|
||||
<div v-if="showSlider" class="f">
|
||||
<span class="f-label">Small slider on the tile</span>
|
||||
<Segmented :choices="[['none', 'No'], ['slider', 'Yes, control directly']]" :value="inline" @pick="(v) => setTileOption(tile, 'inline', v)" />
|
||||
<small v-if="sliderWarn" class="warn">Home Assistant has nothing a slider can change for this entity. Choose No.</small>
|
||||
</div>
|
||||
<div v-if="domain === 'sensor'" class="f">
|
||||
<span class="f-label">History</span>
|
||||
<Segmented :choices="[[1, '1 hour'], [6, '6 hours'], [24, '24 hours']]" :value="history" @pick="(v) => setTileOption(tile, 'history_hours', Number(v))" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">Pastel background</span>
|
||||
<div class="sw">
|
||||
<button v-for="[key, choice] in backgrounds" :key="key" type="button" :aria-label="`Background: ${choice.label}`"
|
||||
:aria-pressed="(tile.options?.background || 'auto') === key ? 'true' : 'false'" @click="setTileOption(tile, 'background', key)">
|
||||
<i :class="choice.color ? '' : key === 'none' ? 'none' : 'auto'" :style="choice.color ? { background: choice.color } : undefined"></i>{{ choice.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTile(tile)">Remove</button>
|
||||
<span class="spacer"></span>
|
||||
<button v-if="domain !== 'screen'" type="button" class="btn quiet" @click="inspect">Read current data</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { dismissToast, state } from "../store";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="state.toast" id="toast" class="toast" role="status">
|
||||
<span>{{ state.toast.message }}</span>
|
||||
<button v-if="state.toast.action" type="button" @click="state.toast.action.run(); dismissToast()">{{ state.toast.action.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
// Adding to the top bar: the screen's own items, Home Assistant's suggestions, or any entity.
|
||||
import { computed, ref } from "vue";
|
||||
import { BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import { addTopbarItem, automaticIcon, closeInspector, iconNamed, settingValues, state, topbarItems, topbarMax } from "../store";
|
||||
import type { HeaderItem } from "../types";
|
||||
|
||||
const query = ref("");
|
||||
const taken = computed(() => new Set(topbarItems().map(itemKey)));
|
||||
const clock24 = computed(() => settingValues().clock_24h !== false);
|
||||
const samples = computed(() => ({ clock: clockText(clock24.value, new Date(state.now)), analog: "Small dial with the time", date: dateText(new Date(state.now)) } as Record<string, string>));
|
||||
const suggested = computed(() => state.inventory.header?.suggestions?.[state.selected || ""] || []);
|
||||
const matches = computed(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
return state.inventory.entities.filter((e) => `${e.name} ${e.id} ${e.area || ""} ${e.device || ""}`.toLocaleLowerCase().includes(q));
|
||||
});
|
||||
const entityItem = (id: string): HeaderItem => ({ type: "entity", entity: id, content: "state", icon: "auto", show: "always" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av" style="background: var(--seg); color: var(--ink-2); font-size: 18px">+</span>
|
||||
<span class="tx"><b>Add to the top bar</b><small>{{ topbarItems().length }} of {{ topbarMax() }} slots used</small></span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<span class="f-label">From the screen itself</span>
|
||||
<div class="options">
|
||||
<button v-for="b in state.inventory.header?.builtin || []" :key="b.type" type="button" class="option" :disabled="taken.has(itemKey({ type: b.type }))" @click="addTopbarItem({ type: b.type })">
|
||||
<span class="mdi">{{ glyph(iconNamed(BUILTIN_ICONS[b.type])?.cp || "F0150") }}</span>
|
||||
<span class="tx"><strong>{{ b.label }}</strong><small>{{ taken.has(itemKey({ type: b.type })) ? "Already added" : samples[b.type] }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="suggested.length" class="f">
|
||||
<span class="f-label">Suggestions from Home Assistant</span>
|
||||
<div class="options">
|
||||
<button v-for="s in suggested" :key="itemKey(s.item)" type="button" class="option" :disabled="taken.has(itemKey(s.item))" @click="addTopbarItem(s.item)">
|
||||
<span class="mdi">{{ glyph(s.icon || automaticIcon(s.item.entity!)) }}</span>
|
||||
<span class="tx"><strong>{{ s.label }}</strong><small>{{ taken.has(itemKey(s.item)) ? "Already added" : [s.name, s.area].filter(Boolean).join(" · ") }}</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f">
|
||||
<label class="f-label" for="topbar-search">An entity</label>
|
||||
<input id="topbar-search" v-model="query" type="search" placeholder="Search by name, room, or entity, e.g. temperature or door" aria-label="Search for an entity for the top bar" />
|
||||
<div class="options">
|
||||
<button v-for="e in matches.slice(0, 40)" :key="e.id" type="button" class="option" :disabled="taken.has(itemKey(entityItem(e.id)))" @click="addTopbarItem(entityItem(e.id))">
|
||||
<span class="mdi">{{ glyph(automaticIcon(e.id)) }}</span>
|
||||
<span class="tx"><strong>{{ e.name }}</strong><small>{{ [e.area, e.id].filter(Boolean).join(" · ") }}</small></span>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="hint">No entities found.</p>
|
||||
<p v-else-if="matches.length > 40" class="hint">{{ matches.length }} results. Keep typing to narrow it down.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dr-foot">
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" @click="closeInspector">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
// The top bar: the name on the left; on the right up to six items: the time, an analog clock, the date, or an
|
||||
// entity's state or last change. Choices apply live; the bar on every page follows.
|
||||
import { computed, ref } from "vue";
|
||||
import { barLayout, BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
|
||||
import {
|
||||
automaticIcon, barMetrics, closeInspector, entityName, iconNamed, markDirty, moveTopbarItem, openBar, openBarAdd,
|
||||
removeTopbarItem, setSetting, settingValues, setTopbarItems, state, supports, topbarItems, topbarLabel, topbarMax, topbarView,
|
||||
} from "../store";
|
||||
import type { HeaderItem } from "../types";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
import Segmented from "./Segmented.vue";
|
||||
import TopbarSvg from "./TopbarSvg.vue";
|
||||
|
||||
const props = defineProps<{ index: number }>();
|
||||
const items = computed(() => topbarItems());
|
||||
const item = computed<HeaderItem | undefined>(() => items.value[props.index]);
|
||||
const lay = computed(() => {
|
||||
void state.fontsVersion; void state.now; void state.topbarPreviews;
|
||||
return barLayout(items.value, barMetrics.value, state.layout?.title || "Home", topbarView);
|
||||
});
|
||||
const overflow = computed(() => lay.value.dropped);
|
||||
const needed = computed(() => state.inventory.header?.min_firmware || "0.2.32");
|
||||
const supported = computed(() => { const [a, b, c] = needed.value.split(".").map(Number); return supports(a, b, c); });
|
||||
const hint = computed(() => supported.value
|
||||
? overflow.value.size ? "Not everything fits next to the name: the screen drops the dashed items. Remove one or choose a shorter name." : "Drag to reorder; tap an item to configure it."
|
||||
: `Sensors, the date, and the analog clock appear from firmware ${needed.value}; until that update, this screen shows the name and, if the time is in the bar, the clock.`);
|
||||
const detail = (it: HeaderItem, i: number) => {
|
||||
const view = topbarView(it);
|
||||
return !view.shown ? "Hidden: not active right now" : overflow.value.has(i) ? "Doesn't fit next to the name" : view.analog ? "Dial" : view.text;
|
||||
};
|
||||
const iconOf = (it: HeaderItem) => {
|
||||
const view = topbarView(it);
|
||||
return view.analog || it.type !== "entity" ? iconNamed(BUILTIN_ICONS[it.type])?.cp : view.icon;
|
||||
};
|
||||
const justAdded = (it: HeaderItem) => state.topbarAdded?.key === itemKey(it) && Date.now() - state.topbarAdded.time < 1200;
|
||||
function rename(value: string) {
|
||||
if (!state.layout) return;
|
||||
state.layout.title = value;
|
||||
markDirty();
|
||||
}
|
||||
function update(patch: Partial<HeaderItem>) {
|
||||
const list = [...items.value];
|
||||
list[props.index] = { ...list[props.index], ...patch };
|
||||
setTopbarItems(list);
|
||||
}
|
||||
const liveNote = computed(() => {
|
||||
if (!item.value) return "";
|
||||
const view = topbarView(item.value);
|
||||
return !view.shown ? "Hidden right now: not active" : overflow.value.has(props.index) ? "Doesn't currently fit next to the name" : "How it looks on the screen";
|
||||
});
|
||||
const clock24 = computed(() => settingValues().clock_24h !== false);
|
||||
const samples = computed(() => ({ clock: clockText(clock24.value, new Date(state.now)), analog: "Small dial with the time", date: dateText(new Date(state.now)) } as Record<string, string>));
|
||||
|
||||
// Pointer drag between the rows, mouse and touch (touch after a short hold, so the list still scrolls). The order
|
||||
// updates while dragging, the mockup follows, and a finished drag is not a click.
|
||||
const drag = ref({ index: -1, active: false, moved: false });
|
||||
let start: { x: number; y: number } | null = null, timer = 0, pointerId: number | null = null, suppressUntil = 0;
|
||||
function down(e: PointerEvent, i: number) {
|
||||
if (e.button !== 0 || (e.target as HTMLElement).closest(".x")) return;
|
||||
if (e.pointerType !== "touch") e.preventDefault();
|
||||
drag.value = { index: i, active: false, moved: false };
|
||||
start = { x: e.clientX, y: e.clientY };
|
||||
pointerId = e.pointerId;
|
||||
clearTimeout(timer);
|
||||
if (e.pointerType === "touch") timer = window.setTimeout(begin, 260);
|
||||
document.addEventListener("pointermove", move);
|
||||
document.addEventListener("pointerup", end);
|
||||
document.addEventListener("pointercancel", end);
|
||||
}
|
||||
function begin() {
|
||||
drag.value.active = true;
|
||||
document.addEventListener("touchmove", block, { passive: false });
|
||||
}
|
||||
function block(e: TouchEvent) { if (drag.value.active) e.preventDefault(); }
|
||||
function move(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId || !start) return;
|
||||
if (!drag.value.active) {
|
||||
const distance = Math.hypot(e.clientX - start.x, e.clientY - start.y);
|
||||
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(timer); start = null; } return; }
|
||||
if (distance < 6) return;
|
||||
begin();
|
||||
}
|
||||
// The target is the row whose middle the pointer passed.
|
||||
const rows = [...document.querySelectorAll<HTMLElement>(".items .item[data-index]")];
|
||||
let target = drag.value.index;
|
||||
rows.forEach((row, i) => {
|
||||
const r = row.getBoundingClientRect();
|
||||
if (i < drag.value.index && e.clientY < r.top + r.height / 2) target = Math.min(target, i);
|
||||
if (i > drag.value.index && e.clientY > r.top + r.height / 2) target = Math.max(target, i);
|
||||
});
|
||||
if (target !== drag.value.index && state.layout) {
|
||||
const list = [...items.value];
|
||||
list.splice(target, 0, ...list.splice(drag.value.index, 1));
|
||||
state.layout.header = { items: list };
|
||||
const selectedMoved = props.index === drag.value.index;
|
||||
drag.value.index = target;
|
||||
drag.value.moved = true;
|
||||
if (selectedMoved) openBar(target);
|
||||
}
|
||||
}
|
||||
function end(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("pointermove", move);
|
||||
document.removeEventListener("pointerup", end);
|
||||
document.removeEventListener("pointercancel", end);
|
||||
document.removeEventListener("touchmove", block);
|
||||
if (drag.value.active) {
|
||||
suppressUntil = Date.now() + 400;
|
||||
if (drag.value.moved) markDirty();
|
||||
}
|
||||
drag.value = { index: -1, active: false, moved: false };
|
||||
start = null; pointerId = null;
|
||||
}
|
||||
function pick(i: number) {
|
||||
if (Date.now() < suppressUntil) return;
|
||||
openBar(i);
|
||||
}
|
||||
function onKey(e: KeyboardEvent, i: number) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openBar(i); return; }
|
||||
const step = ({ ArrowUp: -1, ArrowDown: 1 } as Record<string, number>)[e.key];
|
||||
if (step && moveTopbarItem(i, i + step)) { if (props.index === i) openBar(i + step); e.preventDefault(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dr-head">
|
||||
<span class="av mdi" style="background: var(--seg); color: var(--ink-2)">{{ item ? glyph(iconOf(item) || "F0150") : glyph("F0150") }}</span>
|
||||
<span class="tx">
|
||||
<b>{{ item ? (item.type === "entity" ? entityName(item.entity!) : topbarLabel(item)) : "Top bar" }}</b>
|
||||
<small :class="{ mono: item?.type === 'entity' }">{{ item ? (item.type === "entity" ? item.entity : "From the screen itself, also works without Home Assistant") : `${items.length} of ${topbarMax()} items` }}</small>
|
||||
</span>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="closeInspector">✕</button>
|
||||
</div>
|
||||
<div class="dr-body">
|
||||
<div class="f">
|
||||
<label class="f-label" for="title">Left: name</label>
|
||||
<input id="title" :value="state.layout?.title" maxlength="60" placeholder="For example: Living room" @input="rename(($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label" id="topbar-caption">Right: time, sensors, or a clock <span style="text-transform: none; letter-spacing: 0; font-weight: 500"> · {{ items.length }} / {{ topbarMax() }}</span></span>
|
||||
<div class="items" id="topbar-chips" role="list" aria-labelledby="topbar-caption">
|
||||
<div v-for="(it, i) in items" :key="itemKey(it) + i" class="item" role="listitem" tabindex="0" :data-index="i"
|
||||
:class="{ selected: i === index, 'is-hidden': !topbarView(it).shown, 'is-overflow': overflow.has(i), 'just-added': justAdded(it), 'dragging-chip': drag.active && drag.index === i }"
|
||||
:aria-label="`${topbarLabel(it)}, slot ${i + 1}. Enter: configure, arrows: move`"
|
||||
@pointerdown="down($event, i)" @click="pick(i)" @keydown="onKey($event, i)">
|
||||
<span class="grip" aria-hidden="true">⋮⋮</span>
|
||||
<span class="av mdi" :style="topbarView(it).color ? { color: topbarView(it).color! } : undefined">{{ iconOf(it) ? glyph(iconOf(it)!) : "" }}</span>
|
||||
<span class="tx"><b>{{ topbarLabel(it) }}</b><small>{{ detail(it, i) }}</small></span>
|
||||
<button type="button" class="x" :aria-label="`Remove ${topbarLabel(it)} from the top bar`" @click.stop="removeTopbarItem(i)">✕</button>
|
||||
</div>
|
||||
<button type="button" class="ghost-btn" id="topbar-add" :disabled="items.length >= topbarMax()" :title="items.length >= topbarMax() ? `Maximum ${topbarMax()} items` : 'Add the time, date, analog clock, or an entity'" @click="openBarAdd">+ Add time, date, or an entity</button>
|
||||
</div>
|
||||
<small id="topbar-hint" :class="{ warn: overflow.size > 0 && supported }">{{ hint }}</small>
|
||||
</div>
|
||||
<template v-if="item">
|
||||
<div class="live" id="topbar-live">
|
||||
<small>{{ liveNote }}</small>
|
||||
<TopbarSvg :items="[item]" single />
|
||||
</div>
|
||||
<template v-if="item.type === 'entity'">
|
||||
<div class="f">
|
||||
<span class="f-label">What to show</span>
|
||||
<Segmented :choices="(state.inventory.header?.contents || []).map((c) => [c.key, c.label] as [string, string])" :value="item.content" @pick="(v) => update({ content: v })" />
|
||||
<small>Last changed keeps counting on the screen itself: “Just now”, “5 min ago”, “Yesterday”.</small>
|
||||
</div>
|
||||
<IconPicker :selected="item.icon || 'auto'" :automatic="state.topbarPreviews[itemKey(item)]?.auto_icon || automaticIcon(item.entity!)" auto-label="Automatic (like Home Assistant)" allow-none @pick="(n) => update({ icon: n })" />
|
||||
<div class="f">
|
||||
<span class="f-label">Show</span>
|
||||
<Segmented :choices="(state.inventory.header?.shows || []).map((s) => [s.key, s.label] as [string, string])" :value="item.show" @pick="(v) => update({ show: v })" />
|
||||
<small>Only when active hides the item as long as it's off, closed, away, or 0. Handy for an open door, a running washing machine, or who's home.</small>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="item.type !== 'date'" class="f">
|
||||
<span class="f-label">Format</span>
|
||||
<Segmented :choices="[['24', '24 hour'], ['12', '12 hour']]" :value="clock24 ? '24' : '12'" @pick="(v) => setSetting('clock_24h', v === '24', 150)" />
|
||||
<small>The screen's own clock setting: it applies at once to every clock on this screen, including the clock tiles.</small>
|
||||
</div>
|
||||
<small v-else>{{ samples.date }} today. The date follows the screen's own clock.</small>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="item" class="dr-foot">
|
||||
<button type="button" class="btn danger" @click="removeTopbarItem(index)">Remove</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="btn quiet" :disabled="index === 0" @click="moveTopbarItem(index, index - 1) && openBar(index - 1)">↑ Up</button>
|
||||
<button type="button" class="btn quiet" :disabled="index >= items.length - 1" @click="moveTopbarItem(index, index + 1) && openBar(index + 1)">↓ Down</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
// The bar at the top of a mockup page, drawn as the screen draws it: name left, items right.
|
||||
import { computed } from "vue";
|
||||
import { barLayout, dotted, type BarPart } from "../model/topbar";
|
||||
import { barMetrics, state, topbarItems, topbarView } from "../store";
|
||||
|
||||
const props = defineProps<{ items?: any[]; nameText?: string; single?: boolean }>();
|
||||
|
||||
const lay = computed(() => {
|
||||
void state.fontsVersion;
|
||||
void state.now;
|
||||
void state.topbarPreviews;
|
||||
const items = props.items || topbarItems();
|
||||
return barLayout(items, barMetrics.value, props.nameText ?? (state.layout?.title || "Home"), topbarView);
|
||||
});
|
||||
const m = computed(() => lay.value.metrics);
|
||||
const height = computed(() => (props.single ? Math.round(m.value.text * 1.4) : m.value.top + Math.round(m.value.name * 0.45)));
|
||||
const baseline = computed(() => (props.single ? Math.round(m.value.text * 1.05) : m.value.top));
|
||||
const parts = computed<BarPart[]>(() => {
|
||||
if (!props.single) return lay.value.placed;
|
||||
const part = lay.value.parts[0];
|
||||
if (part) part.x = 0;
|
||||
return part ? [part] : [];
|
||||
});
|
||||
const viewBox = computed(() => (props.single
|
||||
? `-2 0 ${Math.max(1, parts.value[0]?.width || 1) + 4} ${height.value}`
|
||||
: `0 0 ${m.value.width} ${height.value}`));
|
||||
const capMiddle = computed(() => baseline.value + (lay.value.zero.top + lay.value.zero.bottom) / 2);
|
||||
const name = computed(() => dotted(lay.value.nameText, lay.value.fonts.name, Math.min(lay.value.natural, lay.value.nameRoom)));
|
||||
const now = computed(() => new Date(state.now));
|
||||
function hands(d: number) {
|
||||
const n = now.value;
|
||||
return [[(n.getHours() % 12 + n.getMinutes() / 60) * 30, d * 0.24], [n.getMinutes() * 6, d * 0.34]].map(([angle, length]) => {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
return { x2: length * Math.sin(rad), y2: -length * Math.cos(rad) };
|
||||
});
|
||||
}
|
||||
const iconX = (p: BarPart) => p.x! - p.icon!.ink.left;
|
||||
const textX = (p: BarPart) => (p.icon ? p.x! + (p.icon.ink.right - p.icon.ink.left) + lay.value.gaps.icon : p.x!) - p.text!.ink.left;
|
||||
// `font` goes through CSSOM: the add-on's CSP blocks style attributes, and Vue sets styles through the CSSOM.
|
||||
const singleWidth = computed(() => `${(Math.max(1, parts.value[0]?.width || 1) + 4) * (26 / m.value.icon)}px`);
|
||||
defineExpose({ lay });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :viewBox="viewBox" role="img" :aria-label="single ? 'How it looks on the screen' : `Top bar: ${lay.nameText}`" :style="single ? { width: singleWidth } : undefined">
|
||||
<text v-if="!single" x="0" :y="m.top" fill="#1b1b1b" :style="{ font: lay.fonts.name }">{{ name }}</text>
|
||||
<g>
|
||||
<template v-for="p in parts" :key="p.index">
|
||||
<template v-if="p.dial">
|
||||
<circle :cx="p.x! + p.dial / 2" :cy="capMiddle" :r="p.dial / 2 - Math.max(1, Math.round(p.dial / 10)) / 2" fill="none" :stroke="p.view.color || '#46525e'" :stroke-width="Math.max(1, Math.round(p.dial / 10))" />
|
||||
<line v-for="(h, i) in hands(p.dial)" :key="i" :x1="p.x! + p.dial / 2" :y1="capMiddle" :x2="p.x! + p.dial / 2 + h.x2" :y2="capMiddle + h.y2" :stroke="p.view.color || '#46525e'" :stroke-width="Math.max(1, Math.round(p.dial * 0.075))" stroke-linecap="round" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<text v-if="p.icon" :x="iconX(p)" :y="capMiddle - (p.icon.ink.top + p.icon.ink.bottom) / 2" :fill="p.view.color || '#46525e'" :style="{ font: lay.fonts.icon }">{{ p.icon.glyph }}</text>
|
||||
<text v-if="p.text" :x="textX(p)" :y="baseline" fill="#46525e" :style="{ font: lay.fonts.text }">{{ p.text.value }}</text>
|
||||
</template>
|
||||
</template>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
Reference in New Issue
Block a user