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:
Max Gramser
2026-09-18 17:25:23 +02:00
parent c09e4e333f
commit 1a6308aba7
80 changed files with 2623 additions and 320 deletions
+3 -3
View File
@@ -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">
+2 -3
View File
@@ -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);
+1 -1
View File
@@ -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 -4
View File
@@ -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>
+1
View File
@@ -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>
+4 -4
View File
@@ -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" />
+32 -6
View File
@@ -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)" />