Show live values on the mockup, blink a screen to identify it, try an alert, copy and share layouts (0.2.74)
The tiles draw what Home Assistant reports (a new /api/states answers for the tiles on the page); the ··· menu gets Identify (the screen's own show_alert with flash), Copy layout from another screen, Export and Import as JSON; the Alerts page opens with Try it (/api/alerts/test, one screen or all); the library filters by room and hides what is placed, with lit icons for what is on; ⌘K searches screens, entities and actions; the Update badge lists what's new from the changelog (copied into the image) and a running update shows a progress bar with the ESPHome stage and the last log line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+9
-1
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted } from "vue";
|
||||
import Sidebar from "./components/Sidebar.vue";
|
||||
import Toast from "./components/Toast.vue";
|
||||
import CommandPalette from "./components/CommandPalette.vue";
|
||||
import ScreenView from "./components/ScreenView.vue";
|
||||
import EmptyState from "./components/EmptyState.vue";
|
||||
import AppSettingsView from "./components/AppSettingsView.vue";
|
||||
@@ -19,6 +20,12 @@ const view = computed(() => {
|
||||
if (route.value === "#override") return OverrideView;
|
||||
return currentScreen.value && state.layout ? ScreenView : EmptyState;
|
||||
});
|
||||
// ⌘K (Ctrl+K) opens the search from anywhere.
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); state.palette = !state.palette; }
|
||||
}
|
||||
onMounted(() => document.addEventListener("keydown", onKey));
|
||||
onBeforeUnmount(() => document.removeEventListener("keydown", onKey));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,5 +35,6 @@ const view = computed(() => {
|
||||
<component :is="view" />
|
||||
</main>
|
||||
<Toast />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
// Alerts: the cheatsheet for esphome.<node>_show_alert, built from the inventory.
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { copyText, go, state } from "../store";
|
||||
import { canAlert, copyText, go, sendTestAlert, state } from "../store";
|
||||
|
||||
const alerts = computed(() => state.inventory.alerts);
|
||||
// Try it: the same seven fields an automation sends, to one screen or to all of them.
|
||||
const tryForm = reactive({ screen: "all", title: "Someone is at the door", subtitle: "Door 3, back", icon: "doorbell", color: "orange", button_text: "Coming", timeout: 30, flash: true });
|
||||
const trying = ref(false);
|
||||
const tryResult = ref("");
|
||||
const readyScreens = computed(() => state.inventory.screens.filter((s) => canAlert(s) && s.online));
|
||||
async function tryAlert() {
|
||||
trying.value = true;
|
||||
tryResult.value = "";
|
||||
try {
|
||||
const { screen, ...data } = tryForm;
|
||||
const result = await sendTestAlert(screen, data);
|
||||
const where = screen === "all" ? `${result.sent} screen${result.sent === 1 ? "" : "s"}` : state.inventory.screens.find((s) => s.id === screen)?.name || "the screen";
|
||||
tryResult.value = result.sent
|
||||
? `Sent to ${where}.${result.skipped ? ` ${result.skipped} skipped (offline or old firmware).` : ""}${result.unusable?.length ? ` Left empty: ${result.unusable.join(", ")}.` : ""}`
|
||||
: "No screen could show it: check that a screen is online with firmware " + (alerts.value?.min_firmware || "0.2.31") + " or newer.";
|
||||
} catch (e: any) {
|
||||
tryResult.value = e.message;
|
||||
} finally {
|
||||
trying.value = false;
|
||||
}
|
||||
}
|
||||
const icons = computed(() => state.inventory.icons);
|
||||
const exampleAction = ref("");
|
||||
const iconQuery = ref("");
|
||||
@@ -60,7 +81,7 @@ const doorbell = computed(() => alerts.value?.suggested_icons?.find((i: any) =>
|
||||
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"]];
|
||||
const sections = [["alerts-try", "Try it"], ["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>
|
||||
@@ -88,6 +109,30 @@ const sections = [["alerts-screens", "Your screens"], ["alerts-howto", "In an au
|
||||
<button v-for="[id, label] in sections" :key="id" type="button" :data-jump="id" @click="jump(id)">{{ label }}</button>
|
||||
</nav>
|
||||
</div>
|
||||
<section id="alerts-try" class="card">
|
||||
<h2>Try it</h2>
|
||||
<p>Send one alert now, with the same fields an automation sends. The screen shows the card and, with blinking on, flashes its backlight.</p>
|
||||
<form class="try-grid" @submit.prevent="tryAlert">
|
||||
<div class="field"><label class="f-label" for="try-screen">Screen</label>
|
||||
<select id="try-screen" v-model="tryForm.screen">
|
||||
<option value="all">All screens ({{ readyScreens.length }} ready)</option>
|
||||
<option v-for="screen in state.inventory.screens" :key="screen.id" :value="screen.id" :disabled="!canAlert(screen) || !screen.online">{{ screen.name }}{{ canAlert(screen) && screen.online ? "" : " · not ready" }}</option>
|
||||
</select></div>
|
||||
<div class="field"><label class="f-label" for="try-title">Title</label><input id="try-title" v-model="tryForm.title" maxlength="64" /></div>
|
||||
<div class="field"><label class="f-label" for="try-subtitle">Subtitle</label><input id="try-subtitle" v-model="tryForm.subtitle" maxlength="240" /></div>
|
||||
<div class="field"><label class="f-label" for="try-icon">Icon</label><input id="try-icon" v-model="tryForm.icon" list="try-icons" placeholder="doorbell" />
|
||||
<datalist id="try-icons"><option v-for="icon in alerts.suggested_icons" :key="icon.name" :value="icon.name">{{ icon.label || icon.name }}</option></datalist></div>
|
||||
<div class="field"><label class="f-label" for="try-color">Color</label>
|
||||
<select id="try-color" v-model="tryForm.color"><option value="">White (default)</option><option v-for="colour in alerts.colors" :key="colour.name" :value="colour.name">{{ colour.label }}</option></select></div>
|
||||
<div class="field"><label class="f-label" for="try-button">Button text</label><input id="try-button" v-model="tryForm.button_text" maxlength="16" /></div>
|
||||
<div class="field"><label class="f-label" for="try-timeout">Timeout (seconds, 0 waits for the button)</label><input id="try-timeout" v-model.number="tryForm.timeout" type="number" min="0" max="86400" /></div>
|
||||
<label class="check field"><input type="checkbox" id="try-flash" v-model="tryForm.flash" /><span>Blink the backlight<small>Four times when the alert arrives.</small></span></label>
|
||||
<div class="actions field wide">
|
||||
<button type="submit" class="btn primary" id="try-send" :disabled="trying || !readyScreens.length">{{ trying ? "Sending…" : "Send alert" }}</button>
|
||||
<span class="status-line" id="try-result" role="status">{{ tryResult }}</span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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";
|
||||
import { anyUpdating, go, installClaudeSkill, runUpdateAll, setAutoUpdate, state, updateProgress } from "../store";
|
||||
|
||||
const u = computed(() => state.inventory.updates);
|
||||
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
|
||||
@@ -12,6 +12,16 @@ const updatesHint = computed(() => !u.value ? "" : u.value.busy
|
||||
: outdated.value
|
||||
? `Firmware ${u.value.target} is available for ${outdated.value} screen${outdated.value === 1 ? "" : "s"}.`
|
||||
: `All screens have firmware ${u.value.target}.`);
|
||||
const running = computed(() => state.inventory.screens.find((s) => s.update?.state === "running" || state.updating.includes(s.id)));
|
||||
const progress = computed(() => (running.value ? updateProgress(running.value) : null));
|
||||
const logTail = computed(() => (state.firmwareJob?.logs || []).slice(-12).join("\n"));
|
||||
// What the current firmware brings: the changelog sections that mention it.
|
||||
const targetNotes = computed(() => {
|
||||
const sections = (u.value as any)?.changelog as { app: string; firmware: string; lines: string[] }[] | undefined;
|
||||
const target = u.value?.target;
|
||||
if (!sections || !target) return [];
|
||||
return sections.filter((s) => s.firmware === target).flatMap((s) => s.lines).slice(0, 10);
|
||||
});
|
||||
const skill = computed(() => state.inventory.claude_skill);
|
||||
const installing = ref(false);
|
||||
async function install() {
|
||||
@@ -41,7 +51,16 @@ async function install() {
|
||||
<section v-if="u" class="card updates" id="updates">
|
||||
<h2>Firmware updates</h2>
|
||||
<p id="updates-hint">{{ updatesHint }}</p>
|
||||
<template v-if="running && progress">
|
||||
<div class="progress" role="progressbar" :aria-valuenow="progress.percent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: progress.percent + '%' }"></i></div>
|
||||
<div class="progress-text"><span>{{ running.name }} · {{ progress.percent }} %</span><span>{{ progress.text }}</span></div>
|
||||
<pre v-if="logTail" class="log-lines">{{ logTail }}</pre>
|
||||
</template>
|
||||
<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>
|
||||
<details v-if="targetNotes.length && !anyUpdating()" class="whatsnew">
|
||||
<summary>What's new in firmware {{ u.target }}</summary>
|
||||
<ul><li v-for="line in targetNotes" :key="line">{{ line }}</li></ul>
|
||||
</details>
|
||||
<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>
|
||||
@@ -49,7 +68,7 @@ async function install() {
|
||||
</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>
|
||||
<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, and a form to try one.</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>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
// ⌘K: screens, entities for the open screen, and the editor's actions, from one search field.
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { domainInfo } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { addTile, automaticIcon, canAlert, currentScreen, exportLayout, go, identify, save, select, state, tileLimit } from "../store";
|
||||
|
||||
type Item = { group: string; label: string; detail?: string; icon?: string; glyphText?: string; key?: string; run: () => void };
|
||||
const query = ref("");
|
||||
const active = ref(0);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const items = computed<Item[]>(() => {
|
||||
const q = query.value.trim().toLocaleLowerCase();
|
||||
const list: Item[] = [];
|
||||
for (const screen of state.inventory.screens)
|
||||
list.push({ group: "Screens", label: screen.name, detail: `${screen.online ? "Online" : "Offline"} · ${screen.firmware || "unknown"}`, glyphText: "▦", run: () => select(screen.id) });
|
||||
const screen = currentScreen.value;
|
||||
const actions: Item[] = [
|
||||
{ group: "Actions", label: "New screen", detail: "Connect and install", glyphText: "+", run: () => go("#new-screen") },
|
||||
{ group: "Actions", label: "Firmware & USB", icon: "F0241", run: () => go("#firmware") },
|
||||
{ group: "Actions", label: "Alerts", detail: "Cheatsheet and a test alert", icon: "F0594", run: () => go("#alerts") },
|
||||
{ group: "Actions", label: "Settings", detail: "Updates, Claude", icon: "F0493", run: () => go("#settings") },
|
||||
];
|
||||
if (screen && state.layout) {
|
||||
actions.unshift(
|
||||
{ group: "Actions", label: "Save & send", detail: state.dirty ? "Unsaved changes" : "Nothing to save", key: "⌘S", run: () => save() },
|
||||
{ group: "Actions", label: "Layout", detail: screen.name, run: () => { go(""); state.tab = "layout"; } },
|
||||
{ group: "Actions", label: "Screen settings", detail: screen.name, run: () => { go(""); state.tab = "settings"; } },
|
||||
{ group: "Actions", label: "Identify this screen", detail: canAlert(screen) ? "Blinks the backlight and shows a card" : "Needs a newer firmware", run: () => { if (canAlert(screen)) identify(screen); } },
|
||||
{ group: "Actions", label: "Export layout", detail: "Download and copy as JSON", run: exportLayout },
|
||||
);
|
||||
}
|
||||
list.push(...actions);
|
||||
if (screen && state.layout && q) {
|
||||
const chosen = new Set(state.layout.tiles.map((t) => t.entity));
|
||||
const full = state.layout.tiles.length >= tileLimit.value;
|
||||
for (const e of state.inventory.entities) {
|
||||
if (e.tile === false || chosen.has(e.id)) continue;
|
||||
if (!`${e.name} ${e.id} ${e.area || ""} ${e.device || ""}`.toLocaleLowerCase().includes(q)) continue;
|
||||
list.push({ group: "Add to this screen", label: e.name, detail: [domainInfo(e.id)[0], e.area].filter(Boolean).join(" · "), icon: state.inventory.icons ? automaticIcon(e.id) : undefined,
|
||||
run: () => { if (!full) addTile(e.id); } });
|
||||
if (list.length > 60) break;
|
||||
}
|
||||
}
|
||||
return q ? list.filter((i) => `${i.label} ${i.detail || ""}`.toLocaleLowerCase().includes(q)) : list;
|
||||
});
|
||||
const grouped = computed(() => {
|
||||
const out: { group: string; items: { item: Item; index: number }[] }[] = [];
|
||||
items.value.forEach((item, index) => {
|
||||
const g = out.find((o) => o.group === item.group) || (out.push({ group: item.group, items: [] }), out[out.length - 1]);
|
||||
g.items.push({ item, index });
|
||||
});
|
||||
return out;
|
||||
});
|
||||
function close() { state.palette = false; }
|
||||
function run(item: Item) { close(); item.run(); }
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") { e.preventDefault(); close(); }
|
||||
else if (e.key === "ArrowDown") { e.preventDefault(); active.value = Math.min(items.value.length - 1, active.value + 1); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); active.value = Math.max(0, active.value - 1); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); const item = items.value[active.value]; if (item) run(item); }
|
||||
}
|
||||
watch(query, () => (active.value = 0));
|
||||
watch(() => state.palette, async (open) => { if (open) { query.value = ""; active.value = 0; await nextTick(); input.value?.focus(); } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="state.palette" class="palette-backdrop" @click="close">
|
||||
<div class="palette" role="dialog" aria-label="Search" @click.stop @keydown="onKey">
|
||||
<input ref="input" v-model="query" id="palette-input" placeholder="Search screens, entities, actions…" aria-label="Search" autocomplete="off" />
|
||||
<div class="palette-list">
|
||||
<template v-for="g in grouped" :key="g.group">
|
||||
<div class="palette-group">{{ g.group }}</div>
|
||||
<button v-for="{ item, index } in g.items" :key="index" type="button" class="palette-item" :class="{ active: index === active }" @mouseenter="active = index" @click="run(item)">
|
||||
<span v-if="item.icon" class="mdi">{{ glyph(item.icon) }}</span>
|
||||
<span v-else class="glyph">{{ item.glyphText || "›" }}</span>
|
||||
<span class="tx"><span>{{ item.label }}</span><small v-if="item.detail">{{ item.detail }}</small></span>
|
||||
<kbd v-if="item.key" class="hint-key">{{ item.key }}</kbd>
|
||||
</button>
|
||||
</template>
|
||||
<p v-if="!items.length" class="palette-empty">Nothing matches. Type part of a screen, entity or action.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<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.
|
||||
// The entities a tile can show, with a search, a filter per domain and per room, and a switch that hides what is
|
||||
// already on the screen. 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";
|
||||
@@ -14,17 +15,27 @@ const FILTERS: [string, string][] = [
|
||||
];
|
||||
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 rooms = computed(() => [...new Set(state.inventory.entities.map((e) => e.area).filter((a): a is string => Boolean(a)))].sort((a, b) => a.localeCompare(b)));
|
||||
const matches = computed(() => {
|
||||
const query = state.search.toLocaleLowerCase(), filter = state.filter;
|
||||
const query = state.search.toLocaleLowerCase(), filter = state.filter, room = state.room;
|
||||
// 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]) &&
|
||||
(!room || e.area === room) &&
|
||||
(!state.hidePlaced || !chosen.value.has(e.id)) &&
|
||||
`${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);
|
||||
// The avatar shows the state at a glance: lit for on, grey for an entity Home Assistant can't reach.
|
||||
const tone = (e: { id: string; state?: string }) => {
|
||||
const domain = e.id.split(".")[0];
|
||||
if (e.state === "unavailable" || e.state === "unknown") return "gone";
|
||||
if (["light", "switch", "input_boolean", "fan"].includes(domain) && e.state === "on") return "on";
|
||||
return "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,20 +48,27 @@ const count = computed(() => state.inventory.entities.length);
|
||||
<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 class="lib-row">
|
||||
<select id="room" v-model="state.room" aria-label="Room">
|
||||
<option value="">All rooms</option>
|
||||
<option v-for="room in rooms" :key="room" :value="room">{{ room }}</option>
|
||||
</select>
|
||||
<button type="button" class="chip-toggle" id="hide-placed" :aria-pressed="state.hidePlaced ? 'true' : 'false'" title="Hide what is already on this screen" @click="state.hidePlaced = !state.hidePlaced">Hide placed</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="av mdi" :class="tone(entity)" :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-if="!matches.length" class="hint">{{ state.hidePlaced && !state.search && !state.filter && !state.room ? "Everything here is already on this screen." : "No entities found. Try a different name, room or filter." }}</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>
|
||||
<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. ⌘K searches everything." }}</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { canAlert, closeInspector, copyLayoutFrom, currentScreen, exportLayout, go, identify, importLayout, save, startUpdate, state } from "../store";
|
||||
import LayoutView from "./LayoutView.vue";
|
||||
import SettingsTab from "./SettingsTab.vue";
|
||||
import Drawer from "./Drawer.vue";
|
||||
@@ -10,26 +10,48 @@ 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);
|
||||
const updateReady = computed(() => screen.value.update?.available && screen.value.online && screen.value.update?.profile && screen.value.update?.host);
|
||||
const others = computed(() => state.inventory.screens.filter((s) => s.id !== screen.value.id && s.layout?.tiles?.length));
|
||||
const copyOpen = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
function closeMenu() { state.menuOpen = false; copyOpen.value = false; }
|
||||
function openOverride() {
|
||||
state.menuOpen = false;
|
||||
closeMenu();
|
||||
state.overrideProfile = screen.value.update?.profile || null;
|
||||
state.overrideFriendly = screen.value.name;
|
||||
go("#override");
|
||||
}
|
||||
function inspectAll() {
|
||||
state.menuOpen = false;
|
||||
closeMenu();
|
||||
state.selectedTile = null;
|
||||
state.inspector = { kind: "inspect" };
|
||||
}
|
||||
function copyFrom(id: string) {
|
||||
closeMenu();
|
||||
if (state.dirty && !confirm("Replace the unsaved layout with a copy of the other screen's?")) return;
|
||||
copyLayoutFrom(id);
|
||||
}
|
||||
function pickFile() { closeMenu(); fileInput.value?.click(); }
|
||||
async function onFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
if (state.dirty && !confirm("Replace the unsaved layout with the imported one?")) return;
|
||||
importLayout(await file.text());
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (state.menuOpen) state.menuOpen = false;
|
||||
if (state.menuOpen) closeMenu();
|
||||
else if (state.palette) return;
|
||||
else if (state.inspector && !(e.target as HTMLElement)?.closest?.(".picker")) closeInspector();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
if (state.dirty) save();
|
||||
}
|
||||
}
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (state.menuOpen && !(e.target as HTMLElement).closest(".head-right .menu, #more")) state.menuOpen = false;
|
||||
if (state.menuOpen && !(e.target as HTMLElement).closest(".head-right .menu, #more")) closeMenu();
|
||||
}
|
||||
onMounted(() => { document.addEventListener("keydown", onKey); document.addEventListener("click", onDocClick); });
|
||||
onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document.removeEventListener("click", onDocClick); });
|
||||
@@ -49,16 +71,26 @@ onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document
|
||||
</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()">
|
||||
<button v-if="state.dirty" id="save" type="button" class="btn primary" :disabled="state.busy" title="⌘S" @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>
|
||||
<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; copyOpen = false">···</button>
|
||||
<div v-if="state.menuOpen" class="menu" role="menu">
|
||||
<button type="button" role="menuitem" id="identify" :disabled="!canAlert(screen) || !screen.online" :title="canAlert(screen) ? '' : 'Needs firmware 0.2.31 or newer'" @click="closeMenu(); identify(screen)">Identify <small>blink the screen</small></button>
|
||||
<button type="button" role="menuitem" id="inspect" @click="inspectAll">Read current data <small>from Home Assistant</small></button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" role="menuitem" id="copy-layout" :disabled="!others.length" :aria-expanded="copyOpen ? 'true' : 'false'" @click.stop="copyOpen = !copyOpen">Copy layout from… <small>{{ others.length ? `${others.length} screen${others.length === 1 ? "" : "s"}` : "no other screen" }}</small></button>
|
||||
<div v-if="copyOpen" class="sub">
|
||||
<button v-for="other in others" :key="other.id" type="button" role="menuitem" @click="copyFrom(other.id)">{{ other.name }} <small>{{ other.layout.tiles.length }} tiles</small></button>
|
||||
</div>
|
||||
<button type="button" role="menuitem" id="export-layout" @click="closeMenu(); exportLayout()">Export layout <small>JSON</small></button>
|
||||
<button type="button" role="menuitem" id="import-layout" @click="pickFile">Import layout… <small>JSON</small></button>
|
||||
<div class="sep"></div>
|
||||
<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>
|
||||
<button type="button" role="menuitem" @click="closeMenu(); go('#firmware')">Firmware & USB</button>
|
||||
<button v-if="updateReady" type="button" role="menuitem" @click="closeMenu(); startUpdate(screen)">Update firmware <small>{{ screen.update?.target }}</small></button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="application/json,.json" hidden @change="onFile" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="body" id="body">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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 { copyText, go, openIntegrations, PHASES, refresh, route, select, startUpdate, state, updateProgress, whatsNew } from "../store";
|
||||
import type { Screen } from "../types";
|
||||
|
||||
const hostFor = ref<string | null>(null);
|
||||
@@ -25,6 +25,10 @@ function startWithHost(screen: Screen) {
|
||||
hostFor.value = null;
|
||||
startUpdate(screen, address);
|
||||
}
|
||||
const lastLog = () => {
|
||||
const lines = state.firmwareJob?.logs || [];
|
||||
return lines.length ? lines[lines.length - 1] : "";
|
||||
};
|
||||
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
|
||||
@@ -38,6 +42,7 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
|
||||
<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>
|
||||
<button type="button" class="search-btn" id="open-palette" @click="state.palette = true">⌕ Search<kbd>⌘K</kbd></button>
|
||||
<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 === '' }">
|
||||
@@ -64,6 +69,16 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
|
||||
<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>
|
||||
<details v-if="whatsNew(screen).length" class="whatsnew">
|
||||
<summary>What's new</summary>
|
||||
<ul><li v-for="line in whatsNew(screen).slice(0, 8)" :key="line">{{ line }}</li></ul>
|
||||
</details>
|
||||
</template>
|
||||
<template v-else-if="updateState(screen)!.kind === 'running' && updateProgress(screen)">
|
||||
<div class="progress" role="progressbar" :aria-valuenow="updateProgress(screen)!.percent" aria-valuemin="0" aria-valuemax="100"><i :style="{ width: updateProgress(screen)!.percent + '%' }"></i></div>
|
||||
<div class="progress-text"><span>{{ updateProgress(screen)!.percent }} %</span><span :title="lastLog()">{{ updateProgress(screen)!.text }}</span></div>
|
||||
<small v-if="lastLog()" :title="lastLog()" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{ lastLog() }}</small>
|
||||
<button type="button" class="btn link mini" style="justify-self: start" @click="go('#firmware')">Full log</button>
|
||||
</template>
|
||||
<small v-else :class="{ failed: updateState(screen)!.kind === 'failed' }">{{ updateState(screen)!.text }}</small>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// A card on the mockup. A placeholder is the tile being dragged, drawn where it will land.
|
||||
// A card on the mockup, drawn with what Home Assistant reports right now. 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 { entityName, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
@@ -25,6 +26,54 @@ 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);
|
||||
|
||||
// ---- Live values ----
|
||||
const current = computed(() => (domain.value === "screen" ? null : liveOf(props.tile.entity)));
|
||||
const gone = computed(() => !current.value || ["unavailable", "unknown", ""].includes(current.value.state));
|
||||
const on = computed(() => Boolean(current.value) && !gone.value && current.value!.state !== "off" && current.value!.state !== "closed" && current.value!.state !== "standby" && current.value!.state !== "idle" && current.value!.state !== "docked");
|
||||
const isOn = computed(() => ["light", "switch", "input_boolean", "fan"].includes(domain.value) && current.value?.state === "on");
|
||||
const unit = computed(() => current.value?.a?.unit_of_measurement as string | undefined);
|
||||
const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1).replace(/_/g, " ");
|
||||
// Home Assistant's words for the weather states, for a Home Assistant that hands us none.
|
||||
const WEATHER_WORDS: Record<string, string> = { "clear-night": "Clear, night", cloudy: "Cloudy", exceptional: "Exceptional", fog: "Fog", hail: "Hail", lightning: "Lightning", "lightning-rainy": "Lightning, rainy", partlycloudy: "Partly cloudy", pouring: "Pouring", rainy: "Rainy", snowy: "Snowy", "snowy-rainy": "Snowy, rainy", sunny: "Sunny", windy: "Windy", "windy-variant": "Windy" };
|
||||
// A scene, script or button has no state worth a word: its state is the moment it last ran.
|
||||
const NO_STATUS = ["scene", "script", "button", "input_button"];
|
||||
// The text under the name: Home Assistant's word where it has one, the value with its unit for a sensor.
|
||||
const status = computed(() => {
|
||||
const c = current.value;
|
||||
if (!c || NO_STATUS.includes(domain.value)) return note.value;
|
||||
if (gone.value) return c.state === "unknown" ? "Unknown" : "Unavailable";
|
||||
const a = c.a || {};
|
||||
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${a.current_temperature}° · ` : ""}${c.word || capital(c.state)}`;
|
||||
if (domain.value === "weather") return `${c.word || WEATHER_WORDS[c.state] || capital(c.state)}${a.temperature !== undefined ? ` · ${a.temperature}°` : ""}`;
|
||||
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${c.word || capital(c.state)} · ${a.current_position} %`;
|
||||
if (domain.value === "media_player" && a.media_title) return `${c.word || capital(c.state)} · ${a.media_title}`;
|
||||
if (domain.value === "sensor") return `${c.state}${unit.value ? ` ${unit.value}` : ""}`;
|
||||
if (domain.value === "timer") return c.word || capital(c.state);
|
||||
return c.word || capital(c.state);
|
||||
});
|
||||
const bigValue = computed(() => (current.value && !gone.value ? current.value.state : "—"));
|
||||
// The small slider's fill, from what the entity reports; off is empty, like the screen's grey fill.
|
||||
const fill = computed(() => {
|
||||
const c = current.value;
|
||||
if (!c || gone.value) return 0;
|
||||
const a = c.a || {};
|
||||
if (domain.value === "light") return c.state === "on" ? (a.brightness !== undefined ? Math.round((a.brightness / 255) * 100) : 100) : 0;
|
||||
if (domain.value === "fan") return c.state === "on" ? (a.percentage ?? 100) : 0;
|
||||
if (domain.value === "cover") return a.current_position ?? (c.state === "open" ? 100 : 0);
|
||||
if (domain.value === "media_player") return Math.round((a.volume_level ?? 0) * 100);
|
||||
if (domain.value === "number" || domain.value === "input_number") {
|
||||
const value = Number(c.state), min = Number(a.min ?? 0), max = Number(a.max ?? 100);
|
||||
return Number.isFinite(value) && max > min ? Math.round(((value - min) / (max - min)) * 100) : 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const sliderStyle = computed(() => ({ background: `linear-gradient(to right, ${fill.value ? "#ffbf38" : "#c9ccd1"} ${fill.value}%, ${fill.value ? "#fff1d3" : "#e6e8ec"} ${fill.value}%)` }));
|
||||
const volumeStyle = computed(() => ({ background: `linear-gradient(to right, #2196f3 ${fill.value}%, #d3e8fb ${fill.value}%)` }));
|
||||
const setpoint = computed(() => {
|
||||
const t = current.value?.a?.temperature;
|
||||
return t !== undefined && t !== null ? `${t}°` : "—";
|
||||
});
|
||||
|
||||
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];
|
||||
@@ -53,33 +102,42 @@ async function onKey(e: KeyboardEvent) {
|
||||
</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="display === 'digital' && domain === 'screen'">
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead"><span class="big">{{ String(now.getHours()).padStart(2, "0") }}:{{ String(now.getMinutes()).padStart(2, "0") }}</span><span class="nm">{{ name }}</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 class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider"></span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></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>
|
||||
<span v-if="controls === 'toggle'" class="tog" :class="{ off: !on }"></span>
|
||||
<span v-else-if="controls === 'setpoint'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>{{ setpoint }}</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>
|
||||
<span v-else-if="controls === 'stepper'" class="stp"><span class="mdi">{{ key("minus") || "−" }}</span><b>{{ bigValue }}</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 === 'volume'"><span class="range" :style="volumeStyle"></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(on ? "pause" : "play") || 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 v-else class="range" :style="{ background: `linear-gradient(to right, #2196f3 ${fill}%, #d3e8fb ${fill}%)` }"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="ic mdi">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead">
|
||||
<span v-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<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 v-if="display !== 'watch' && status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
<span v-if="tile.options?.inline === 'slider'" class="mini-slider" :style="sliderStyle"></span>
|
||||
</span>
|
||||
</template>
|
||||
<button v-if="live" type="button" class="remove" title="Remove tile" :aria-label="`Remove ${name}`" @click.stop="removeTile(tile)">✕</button>
|
||||
|
||||
+164
-5
@@ -7,6 +7,7 @@ import {
|
||||
rowStart, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
|
||||
} from "./model/layout";
|
||||
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
|
||||
import { versionAtLeast } from "./model/layout";
|
||||
import type { Capability, EntityAction, HeaderItem, Inventory, Layout, Screen, Tile } from "./types";
|
||||
|
||||
export type Inspector =
|
||||
@@ -15,6 +16,8 @@ export type Inspector =
|
||||
| { kind: "bar-add" }
|
||||
| { kind: "inspect"; entity?: string };
|
||||
export type DragState = { active: boolean; moving: Tile | null; preview: { tile: Tile; slot: number }[] | null };
|
||||
// What Home Assistant reports for an entity right now: the state, its word and the attributes a card shows.
|
||||
export type Live = { state: string; word?: string | null; a: Record<string, any> };
|
||||
|
||||
export const state = reactive({
|
||||
inventory: { screens: [], entities: [] } as Inventory,
|
||||
@@ -50,6 +53,11 @@ export const state = reactive({
|
||||
overrideFriendly: "",
|
||||
drag: { active: false, moving: null, preview: null } as DragState,
|
||||
menuOpen: false,
|
||||
liveStates: {} as Record<string, Live>,
|
||||
room: "",
|
||||
hidePlaced: false,
|
||||
palette: false,
|
||||
firmwareJob: null as null | { job: any; logs: string[] },
|
||||
});
|
||||
|
||||
export const currentScreen = computed<Screen | undefined>(() => state.inventory.screens.find((s) => s.id === state.selected));
|
||||
@@ -157,6 +165,31 @@ export async function loadEntityActions(entity: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Live values on the mockup (app 0.2.74): what the screen shows right now ----
|
||||
let statesFlight = false;
|
||||
export async function loadStates() {
|
||||
const entities = [...new Set((state.layout?.tiles || []).map((t) => t.entity).filter((id) => !id.startsWith("screen.")))];
|
||||
if (!entities.length || statesFlight) return;
|
||||
statesFlight = true;
|
||||
try {
|
||||
for (let i = 0; i < entities.length; i += 60) {
|
||||
const query = entities.slice(i, i + 60).map((id) => `entity=${encodeURIComponent(id)}`).join("&");
|
||||
Object.assign(state.liveStates, (await getJson(`states?${query}`)).states || {});
|
||||
}
|
||||
} catch {
|
||||
// The next tick tries again; the mockup keeps the last values.
|
||||
} finally {
|
||||
statesFlight = false;
|
||||
}
|
||||
}
|
||||
// The live value, else what the inventory knew when it was fetched, else nothing.
|
||||
export function liveOf(entity: string): Live | null {
|
||||
const live = state.liveStates[entity];
|
||||
if (live) return live;
|
||||
const known = state.inventory.entities.find((e) => e.id === entity);
|
||||
return known?.state ? { state: known.state, word: null, a: {} } : null;
|
||||
}
|
||||
|
||||
// ---- Selecting a screen and editing its layout ----
|
||||
export function markDirty() {
|
||||
state.dirty = true;
|
||||
@@ -176,14 +209,16 @@ export function select(id: string | null) {
|
||||
const screen = state.inventory.screens.find((s) => s.id === id);
|
||||
if (!screen) { state.layout = null; return; }
|
||||
// A plain copy: the inventory is reactive, and structuredClone refuses a proxy.
|
||||
state.layout = JSON.parse(JSON.stringify(screen.layout));
|
||||
normalize(state.layout);
|
||||
state.layout.pages = pageCount(entriesOf(state.layout), state.layout.pages);
|
||||
const layout: Layout = JSON.parse(JSON.stringify(screen.layout));
|
||||
normalize(layout);
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
state.layout = layout;
|
||||
state.insertAt = -1;
|
||||
state.dirty = false;
|
||||
state.saved = 0;
|
||||
loadTopbarPreview(0);
|
||||
loadCapabilities(state.layout.tiles.map((t) => t.entity));
|
||||
loadCapabilities(layout.tiles.map((t) => t.entity));
|
||||
loadStates();
|
||||
go("");
|
||||
}
|
||||
export const liveEntries = () => (state.layout ? entriesOf(state.layout) : []);
|
||||
@@ -202,7 +237,9 @@ export function placeTile(tile: Tile, target: number) {
|
||||
if (!state.layout) return false;
|
||||
loadCapabilities([tile.entity]);
|
||||
const result = arrange(state.layout.tiles, tile, target);
|
||||
return result ? commit(result) : false;
|
||||
const placed = result ? commit(result) : false;
|
||||
if (placed && !state.liveStates[tile.entity]) loadStates();
|
||||
return placed;
|
||||
}
|
||||
// A click in the picker: the marked empty cell, else the first free cell.
|
||||
export function addTile(id: string) {
|
||||
@@ -310,6 +347,120 @@ export async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Identify and the test alert (app 0.2.74): a screen's own show_alert action ----
|
||||
export const canAlert = (screen: Screen | undefined) =>
|
||||
Boolean(screen && screen.alert_action && versionAtLeast(screen.firmware, state.inventory.alerts?.min_firmware || "0.2.31"));
|
||||
export async function identify(screen: Screen) {
|
||||
try {
|
||||
await send(`screens/${encodeURIComponent(screen.id)}/identify`, "POST");
|
||||
toast(`${screen.name} blinks and shows a card for a few seconds.`);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
export async function sendTestAlert(target: string, data: Record<string, unknown>) {
|
||||
return (await send("alerts/test", "POST", { screen: target, data })) as { sent: number; failed: number; skipped: number; unusable?: string[] };
|
||||
}
|
||||
|
||||
// ---- Copying and sharing a layout (app 0.2.74) ----
|
||||
const LAYOUT_KEYS = ["title", "tiles", "header", "pages"] as const;
|
||||
function adopt(source: Partial<Layout>, what: string) {
|
||||
const layout = state.layout;
|
||||
if (!layout) return;
|
||||
const tiles = (Array.isArray(source.tiles) ? source.tiles : [])
|
||||
.filter((t): t is Tile => Boolean(t && typeof t === "object" && typeof t.entity === "string" && t.entity.includes(".")))
|
||||
.map((t) => ({ entity: t.entity, name: typeof t.name === "string" ? t.name : "", slot: Number.isInteger(t.slot) ? t.slot : -1,
|
||||
...(t.options && typeof t.options === "object" ? { options: { ...t.options } } : {}) }) as Tile);
|
||||
const seen = new Set<string>();
|
||||
const unique = tiles.filter((t) => !seen.has(t.entity) && seen.add(t.entity));
|
||||
const kept = unique.slice(0, tileLimit.value);
|
||||
layout.tiles = kept;
|
||||
if (source.header && Array.isArray(source.header.items)) layout.header = { items: source.header.items.map((i) => ({ ...i })) };
|
||||
else delete layout.header;
|
||||
layout.pages = Number.isInteger(source.pages) ? (source.pages as number) : 1;
|
||||
normalize(layout);
|
||||
layout.pages = pageCount(entriesOf(layout), layout.pages);
|
||||
closeInspector();
|
||||
markDirty();
|
||||
loadCapabilities(kept.map((t) => t.entity));
|
||||
loadStates();
|
||||
loadTopbarPreview(0);
|
||||
toast(kept.length < unique.length
|
||||
? `${what}: ${kept.length} of ${unique.length} tiles fit this screen's firmware. Save & send when it looks right.`
|
||||
: `${what}. Save & send when it looks right.`);
|
||||
}
|
||||
export function copyLayoutFrom(id: string) {
|
||||
const other = state.inventory.screens.find((s) => s.id === id);
|
||||
if (!other || !state.layout) return;
|
||||
adopt(JSON.parse(JSON.stringify(other.layout)), `Layout of ${other.name} copied`);
|
||||
}
|
||||
export function layoutJson() {
|
||||
const layout = state.layout;
|
||||
if (!layout) return "";
|
||||
const out: Record<string, unknown> = { esp_screens_layout: 1 };
|
||||
for (const key of LAYOUT_KEYS) if (layout[key] !== undefined) out[key] = layout[key];
|
||||
return JSON.stringify(out, null, 2);
|
||||
}
|
||||
export function exportLayout() {
|
||||
const text = layoutJson();
|
||||
if (!text) return;
|
||||
const name = `${(currentScreen.value?.name || "screen").toLowerCase().replace(/[^a-z0-9]+/g, "-")}.layout.json`;
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = name; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
copyText(text, undefined, "Layout JSON");
|
||||
}
|
||||
export function importLayout(text: string) {
|
||||
let data: any;
|
||||
try { data = JSON.parse(text); } catch { toast("That isn't JSON. Export a layout first, or paste one from another ESP Screens."); return; }
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data.tiles)) { toast("No tiles in this file. Expected a layout exported from ESP Screens."); return; }
|
||||
adopt(data, "Layout imported");
|
||||
}
|
||||
|
||||
// ---- Updates with content (app 0.2.74): what a screen gets, and how far its update is ----
|
||||
export function whatsNew(screen: Screen): string[] {
|
||||
const target = state.inventory.updates?.target;
|
||||
const sections = (state.inventory.updates as any)?.changelog as { app: string; firmware: string; lines: string[] }[] | undefined;
|
||||
if (!sections || !target) return [];
|
||||
const since = screen.firmware;
|
||||
const lines: string[] = [];
|
||||
for (const section of sections) {
|
||||
if (versionAtLeast(section.firmware, target) && section.firmware !== target) continue;
|
||||
if (since && versionAtLeast(since, section.firmware)) continue;
|
||||
for (const line of section.lines) if (!lines.includes(line)) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
let firmwareFlight = false;
|
||||
export async function loadFirmwareJob() {
|
||||
if (firmwareFlight) return;
|
||||
firmwareFlight = true;
|
||||
try {
|
||||
const data = await getJson("firmware");
|
||||
state.firmwareJob = { job: data.job, logs: data.logs || [] };
|
||||
} catch {
|
||||
// Keep what we have.
|
||||
} finally {
|
||||
firmwareFlight = false;
|
||||
}
|
||||
}
|
||||
export const anyUpdating = () => state.inventory.screens.some((s) => s.update?.state === "running") || state.updating.length > 0;
|
||||
// Progress of a running update, from its phase and the ESPHome stage of the build.
|
||||
export function updateProgress(screen: Screen): { percent: number; text: string } | null {
|
||||
const u = screen.update || {};
|
||||
if (!(u.state === "running" || state.updating.includes(screen.id))) return null;
|
||||
const stage = state.firmwareJob?.job?.stage as string | undefined;
|
||||
if (u.phase === "verify") return { percent: 78, text: PHASES.verify };
|
||||
if (u.phase === "settle") return { percent: 92, text: PHASES.settle };
|
||||
if (u.phase === "install" || !u.phase) {
|
||||
if (stage === "upload") return { percent: 66, text: "Writing the firmware over Wi-Fi…" };
|
||||
if (stage) return { percent: 40, text: "Building the firmware…" };
|
||||
return { percent: 12, text: PHASES.install };
|
||||
}
|
||||
return { percent: 12, text: PHASES[u.phase] || "Starting update…" };
|
||||
}
|
||||
|
||||
// ---- Top bar ----
|
||||
// Without a stored top bar the screen shows what it always did: the clock of show_clock.
|
||||
export const topbarItems = (): HeaderItem[] =>
|
||||
@@ -608,6 +759,14 @@ export function boot() {
|
||||
state.now = Date.now();
|
||||
loadTopbarPreview(0);
|
||||
}, 30000);
|
||||
// The mockup follows Home Assistant while it is on screen; a running update reports its stage every few seconds.
|
||||
setInterval(() => {
|
||||
if (!document.hidden && state.layout && state.tab === "layout" && route.value === "") loadStates();
|
||||
}, 8000);
|
||||
setInterval(() => {
|
||||
if (!document.hidden && anyUpdating()) loadFirmwareJob();
|
||||
else if (state.firmwareJob && !anyUpdating()) state.firmwareJob = null;
|
||||
}, 3000);
|
||||
document.addEventListener("visibilitychange", async () => {
|
||||
if (document.hidden) return;
|
||||
lastFull = Date.now();
|
||||
|
||||
@@ -160,6 +160,12 @@ a:hover { text-decoration: underline; }
|
||||
.tile .ctl { flex: none; display: flex; align-items: center; gap: 4px; color: #1b1b1b; }
|
||||
.tile .tog { width: 30px; height: 17px; border-radius: 9px; background: #2196f3; position: relative; }
|
||||
.tile .tog::after { content: ""; position: absolute; top: 2px; right: 2px; width: 13px; height: 13px; border-radius: 50%; background: #fff; }
|
||||
.tile .tog.off { background: #c9ccd1; }
|
||||
.tile .tog.off::after { right: auto; left: 2px; }
|
||||
.tile .ic.lit { color: #d9930f; }
|
||||
.tile .big { font-size: 19px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.05; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tile .big small { display: inline; font-size: 10.5px; font-weight: 500; color: #5a5f66; margin-left: 2px; }
|
||||
.device.cyd .tile .big { font-size: 16px; }
|
||||
.tile .range { width: 56px; height: 14px; border-radius: 7px; background: linear-gradient(to right, #2196f3 35%, #d3e8fb 35%); }
|
||||
.tile .key { height: 20px; min-width: 22px; padding: 0 5px; border-radius: 6px; background: #eceef1; display: grid; place-items: center; font-size: 12px; }
|
||||
.tile .stp { display: flex; align-items: center; gap: 5px; height: 20px; padding: 0 7px; border-radius: 10px; background: #eceef1; font-size: 11px; font-weight: 600; }
|
||||
@@ -423,3 +429,48 @@ td code { white-space: nowrap; }
|
||||
.panel { padding: 16px; }
|
||||
.alert-hero { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Updates with content: progress and what's new */
|
||||
.progress { position: relative; height: 6px; border-radius: 3px; background: var(--seg); overflow: hidden; }
|
||||
.progress i { position: absolute; inset: 0 auto 0 0; background: var(--accent); border-radius: 3px; transition: width 0.4s; }
|
||||
.progress-text { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--muted); }
|
||||
.progress-text span:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: right; }
|
||||
.whatsnew summary { cursor: pointer; font-size: 11.5px; color: var(--accent); font-weight: 500; list-style: none; }
|
||||
.whatsnew summary::-webkit-details-marker { display: none; }
|
||||
.whatsnew ul { margin: 6px 0 0; padding-left: 14px; display: grid; gap: 4px; font-size: 11px; color: var(--ink-2); }
|
||||
.whatsnew ul li { line-height: 1.35; }
|
||||
.log-lines { max-height: 160px; font-size: 11px; }
|
||||
|
||||
/* Command palette */
|
||||
.palette-backdrop { position: fixed; inset: 0; background: rgba(10, 12, 18, 0.28); z-index: 70; display: grid; align-items: start; justify-items: center; padding: 12vh 16px 16px; }
|
||||
.palette { width: min(640px, 100%); background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); overflow: hidden; display: flex; flex-direction: column; max-height: 70vh; }
|
||||
.palette input { border: 0; border-radius: 0; border-bottom: 1px solid var(--line); padding: 14px 16px; font-size: 15px; background: transparent; }
|
||||
.palette-list { overflow: auto; padding: 6px; display: grid; gap: 2px; }
|
||||
.palette-group { font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); padding: 8px 10px 4px; }
|
||||
.palette-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; width: 100%; text-align: left; color: var(--ink); }
|
||||
.palette-item .mdi, .palette-item .glyph { width: 22px; text-align: center; color: var(--muted); font-size: 16px; flex: none; }
|
||||
.palette-item .tx { flex: 1; min-width: 0; display: grid; }
|
||||
.palette-item .tx small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.palette-item .hint-key { font-size: 10.5px; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; }
|
||||
.palette-item.active, .palette-item:hover { background: var(--accent-soft); }
|
||||
.palette-empty { padding: 18px; color: var(--muted); font-size: 12.5px; text-align: center; }
|
||||
.side .search-btn { display: flex; align-items: center; gap: 8px; width: 100%; margin: 4px 0 6px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--muted); font-size: 12px; text-align: left; }
|
||||
.side .search-btn kbd { margin-left: auto; font: 10.5px var(--ui-font); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; background: var(--surface); }
|
||||
.side .search-btn:hover { color: var(--ink); border-color: var(--line-strong); }
|
||||
|
||||
/* Menu with a second level */
|
||||
.menu .sub { display: grid; padding: 2px 0 4px 10px; }
|
||||
.menu .sub button { font-size: 12.5px; padding: 6px 10px; }
|
||||
.menu .sep { height: 1px; background: var(--line); margin: 4px 6px; }
|
||||
|
||||
/* Library extras */
|
||||
.lib-row { display: flex; gap: 6px; align-items: center; }
|
||||
.lib-row select { padding: 5px 8px; font-size: 12px; flex: 1; min-width: 0; }
|
||||
.lib-row .chip-toggle { flex: none; padding: 5px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); }
|
||||
.lib-row .chip-toggle[aria-pressed="true"] { background: var(--ink); color: var(--surface); border-color: var(--ink); }
|
||||
.ent .av.on { background: #fff3d6 !important; color: #b7791f !important; }
|
||||
.ent .av.gone { background: var(--seg) !important; color: var(--muted) !important; }
|
||||
|
||||
/* Try it (alerts) */
|
||||
.try-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
|
||||
.try-grid .field.wide { grid-column: 1 / -1; }
|
||||
|
||||
Reference in New Issue
Block a user