Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { versionAtLeast } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { canAlert, copyText, go, sendTestAlert, state } from "../store";
|
||||
import { canAlert, copyText, firmwareVersion, 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.
|
||||
@@ -141,8 +141,8 @@ const sections = [["alerts-try", "Try it"], ["alerts-screens", "Your screens"],
|
||||
<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 class="chip" :class="versionAtLeast(firmwareVersion(screen), alerts.min_firmware) && screen.alert_action ? 'good' : 'update'">
|
||||
{{ versionAtLeast(firmwareVersion(screen), 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">
|
||||
|
||||
@@ -17,9 +17,8 @@ const progress = computed(() => (running.value ? updateProgress(running.value) :
|
||||
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 [];
|
||||
const sections = state.inventory.changelog, target = u.value?.target;
|
||||
if (!Array.isArray(sections) || !target) return [];
|
||||
return sections.filter((s) => s.firmware === target).flatMap((s) => s.lines).slice(0, 10);
|
||||
});
|
||||
const skill = computed(() => state.inventory.claude_skill);
|
||||
|
||||
@@ -43,7 +43,7 @@ watch(() => props.entity, load);
|
||||
<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">
|
||||
<article v-for="(tile, i) in summary || []" :key="i" class="inspection-tile">
|
||||
<strong>{{ tile.entity }}</strong>
|
||||
<span>Status: {{ tile.word || tile.state }}</span>
|
||||
<small>{{ optionsText(tile.entity) }}</small>
|
||||
|
||||
@@ -6,7 +6,7 @@ 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";
|
||||
import { addTile, automaticIcon, isGuition, repeatable, state, tileLimit } from "../store";
|
||||
|
||||
const FILTERS: [string, string][] = [
|
||||
["", "All"], ["light", "Lights"], ["climate", "Climate"], ["switch", "Switches"], ["binary_sensor", "Status"], ["button", "Actions"],
|
||||
@@ -15,6 +15,8 @@ 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) || []));
|
||||
// On the screen and not to be added again; a page tile can be, when the firmware takes several (0.2.65).
|
||||
const placed = (id: string) => chosen.value.has(id) && !repeatable(id);
|
||||
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, room = state.room;
|
||||
@@ -24,7 +26,7 @@ const matches = computed(() => {
|
||||
(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)) &&
|
||||
(!state.hidePlaced || !placed(e.id)) &&
|
||||
`${e.name} ${e.id} ${e.device || ""} ${e.area || ""}`.toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const full = computed(() => (state.layout?.tiles.length || 0) >= tileLimit.value);
|
||||
@@ -58,13 +60,13 @@ const tone = (e: { id: string; state?: string }) => {
|
||||
</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)">
|
||||
:disabled="placed(entity.id) || full" v-drag="{ kind: 'entity', id: entity.id }" @click="addTile(entity.id)">
|
||||
<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>
|
||||
<span class="add" :class="{ done: placed(entity.id) }">{{ placed(entity.id) ? "✓" : "+" }}</span>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -52,6 +52,7 @@ const pendingText = (p: { installed?: boolean; downloaded?: boolean; file: strin
|
||||
<span>{{ screen.name }}</span>
|
||||
<small>{{ screen.online ? "Online" : "Offline" }}{{ screen.area ? " · " + screen.area : "" }} · {{ screen.firmware || "unknown" }}</small>
|
||||
</span>
|
||||
<span v-if="screen.id === state.selected && state.dirty" class="unsaved" role="img" aria-label="Unsaved changes" title="Unsaved changes"></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>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { computed, nextTick } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { displayNames, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { entityName, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import { entityName, isSelected, liveOf, openTile, placeTile, removeTile, state, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
@@ -21,7 +21,7 @@ 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 chosen = computed(() => isSelected(props.tile) && 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));
|
||||
@@ -77,7 +77,7 @@ const setpoint = computed(() => {
|
||||
});
|
||||
|
||||
async function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile.entity); return; }
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openTile(props.tile); return; }
|
||||
const step = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -2, ArrowDown: 2 } as Record<string, number>)[e.key];
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
@@ -93,7 +93,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<div class="tile" :class="{ wide, full, 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)">
|
||||
v-drag="{ kind: 'tile', tile }" @click="live && openTile(tile)" @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" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<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, MAX_PAGES, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { computed, toRaw } from "vue";
|
||||
import { domainInfo, entriesOf, MAX_PAGES, pageCount, pageOf, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
|
||||
import { glyph } from "../model/topbar";
|
||||
import { automaticIcon, closeInspector, entityName, markDirty, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
import { automaticIcon, closeInspector, entityName, fullPage, markDirty, moveTileToPage, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
import ActionPicker from "./ActionPicker.vue";
|
||||
import IconPicker from "./IconPicker.vue";
|
||||
@@ -14,9 +14,31 @@ const domain = computed(() => props.tile.entity.split(".")[0]);
|
||||
const name = computed(() => entityName(props.tile.entity));
|
||||
// A navigation tile (screen.page_<n>): the page it opens, its size, icon and colour; nothing else applies.
|
||||
const goesTo = computed(() => pageTarget(props.tile.entity));
|
||||
const pages = Array.from({ length: MAX_PAGES }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
|
||||
// Pages counted from 1. "Goes to page" offers the pages the screen has and the empty one after them, where a sub-page
|
||||
// starts (app 0.2.78), and keeps a target beyond those so the choice stays visible.
|
||||
const pageTotal = computed(() => (state.layout ? pageCount(entriesOf(state.layout), state.layout.pages) : 1));
|
||||
const pageHere = computed(() => pageOf(props.tile.slot) + 1);
|
||||
const emptyPage = (n: number) => !state.layout?.tiles.some((t) => pageOf(t.slot) === n - 1);
|
||||
const pages = computed(() => {
|
||||
const list = Array.from({ length: Math.min(MAX_PAGES, pageTotal.value + 1) }, (_, i) => i + 1);
|
||||
if (goesTo.value > list.length) list.push(goesTo.value);
|
||||
return list.map((n) => [n, emptyPage(n) ? `${n} (empty)` : String(n)] as [number, string]);
|
||||
});
|
||||
const goesToHint = computed(() => !fullPage.value
|
||||
? { text: "Navigation tiles need firmware 0.2.62: press Update on the screen first.", warn: false }
|
||||
: goesTo.value > pageTotal.value
|
||||
? { text: `This screen has no page ${goesTo.value}, so a tap opens its last page. Choose another page.`, warn: true }
|
||||
: { text: "A tap on the tile opens that page.", warn: false });
|
||||
// Moving the tile without a drag (app 0.2.78): another page, or a new one after the last. A tile alone on the last page
|
||||
// gets no "New page", which would only leave an empty page behind; with nowhere to go the row stays hidden.
|
||||
const alone = computed(() => !state.layout?.tiles.some((t) => toRaw(t) !== toRaw(props.tile) && pageOf(t.slot) === pageHere.value - 1));
|
||||
const onPage = computed(() => {
|
||||
const list = Array.from({ length: pageTotal.value }, (_, i) => [i + 1, String(i + 1)] as [number, string]);
|
||||
if (pageTotal.value < MAX_PAGES && !(alone.value && pageHere.value === pageTotal.value)) list.push([pageTotal.value + 1, "New page"]);
|
||||
return list;
|
||||
});
|
||||
const sizes = computed<[string, string][]>(() => goesTo.value ? [["single", "Normal"], ["wide", "Double-width"]] : [["single", "Normal"], ["wide", "Double-width"], ["full", "Full page"]]);
|
||||
const sizeHint = computed(() => goesTo.value ? "" : supports(0, 2, 62)
|
||||
const sizeHint = computed(() => goesTo.value ? "" : fullPage.value
|
||||
? "Full page: one big button that lights up while on; a slider, controls or a graph sit at the bottom of it."
|
||||
: "A full-page tile needs firmware 0.2.62: press Update on the screen first.");
|
||||
const caps = computed(() => state.capabilities[props.tile.entity]);
|
||||
@@ -100,7 +122,7 @@ function inspect() {
|
||||
<div v-if="goesTo" class="f">
|
||||
<span class="f-label">Goes to page</span>
|
||||
<Segmented :choices="pages" :value="goesTo" @pick="(v) => retargetPageTile(tile, Number(v))" />
|
||||
<small>{{ supports(0, 2, 62) ? "A tap on the tile opens that page." : "Navigation tiles need firmware 0.2.62: press Update on the screen first." }}</small>
|
||||
<small :class="{ warn: goesToHint.warn }">{{ goesToHint.text }}</small>
|
||||
</div>
|
||||
<div v-else class="f">
|
||||
<span class="f-label">Display</span>
|
||||
@@ -112,6 +134,10 @@ function inspect() {
|
||||
<Segmented :choices="sizes" :value="size" @pick="(v) => setTileOption(tile, 'size', v)" />
|
||||
<small v-if="sizeHint">{{ sizeHint }}</small>
|
||||
</div>
|
||||
<div v-if="onPage.length > 1" class="f">
|
||||
<span class="f-label">Page</span>
|
||||
<Segmented :choices="onPage" :value="pageHere" @pick="(v) => moveTileToPage(tile, Number(v) - 1)" />
|
||||
</div>
|
||||
<div v-if="catalogue && size !== 'single' && !goesTo" class="f">
|
||||
<span class="f-label">Direct control on the tile</span>
|
||||
<Segmented :choices="controlChoices" :value="controls" @pick="(v) => setTileOption(tile, 'controls', v)" />
|
||||
|
||||
Reference in New Issue
Block a user