Release v1.61.0-beta.7 candidate

This commit is contained in:
Matysh
2026-08-11 02:49:42 +03:00
parent d2bc908280
commit c237baaffd
50 changed files with 4432 additions and 2328 deletions
+19 -2
View File
@@ -8,12 +8,17 @@
* localStorage (`houseplan_card_cfg_v1`) for its own instant start — we seed from it
* so embedded cards paint immediately, then refresh from the server in the background.
*/
import { contentFingerprint } from './visual-continuity';
const LS_CFG = 'houseplan_card_cfg_v1';
export interface HpConfigSnapshot {
config: any | null;
rev: number;
configFingerprint: string;
layout: Record<string, any>;
layoutRev: number;
layoutFingerprint: string;
}
let cache: HpConfigSnapshot | null = null;
@@ -27,7 +32,15 @@ export function cachedSnapshot(): HpConfigSnapshot | null {
try {
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
if (c && c.config && Array.isArray(c.config.spaces)) {
return { config: c.config, rev: c.rev || 0, layout: c.layout || {} };
const layout = c.layout || {};
return {
config: c.config,
rev: c.rev || 0,
configFingerprint: c.config_fingerprint || contentFingerprint(c.config),
layout,
layoutRev: c.layout_rev || 0,
layoutFingerprint: c.layout_fingerprint || contentFingerprint(layout),
};
}
} catch {
/* ignore */
@@ -43,7 +56,10 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
cache = {
config: cfgResp?.config ?? null,
rev: cfgResp?.rev ?? 0,
configFingerprint: contentFingerprint(cfgResp?.config ?? null),
layout: layResp?.layout ?? {},
layoutRev: layResp?.rev ?? 0,
layoutFingerprint: contentFingerprint(layResp?.layout ?? {}),
};
if (!subscribed && hass.connection?.subscribeEvents) {
subscribed = true;
@@ -66,7 +82,8 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
}
/** Get the shared config snapshot (cached, deduped across cards). */
export function getConfig(hass: any): Promise<HpConfigSnapshot> {
export function getConfig(hass: any, force = false): Promise<HpConfigSnapshot> {
if (force) cache = null;
if (cache) return Promise.resolve(cache);
if (inflight) return inflight;
inflight = fetchFresh(hass).finally(() => {
+14 -5
View File
@@ -1,6 +1,7 @@
/** Runtime proof that this document can rasterize isolated SVG screen blend. */
const documentProbes = new WeakMap<Document, Promise<boolean>>();
type ProbeState = { promise: Promise<boolean>; resolved?: boolean };
const documentProbes = new WeakMap<Document, ProbeState>();
export type Rgb = readonly [number, number, number];
@@ -59,9 +60,17 @@ async function renderProbe(doc: Document): Promise<boolean> {
/** One cached Promise per real Document; errors are a normal false fallback. */
export function svgScreenBlendSupported(doc: Document): Promise<boolean> {
const cached = documentProbes.get(doc);
if (cached) return cached;
const probe = renderProbe(doc).catch(() => false);
documentProbes.set(doc, probe);
return probe;
if (cached) return cached.promise;
const state: ProbeState = { promise: Promise.resolve(false) };
state.promise = renderProbe(doc).catch(() => false).then((resolved) => {
state.resolved = resolved;
return resolved;
});
documentProbes.set(doc, state);
return state.promise;
}
/** Synchronous warm-remount path: undefined means the first cold probe is pending. */
export function resolvedSvgScreenBlend(doc: Document): boolean | undefined {
return documentProbes.get(doc)?.resolved;
}
+563 -213
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -342,6 +342,9 @@
"space_card.button": "Open the space plan",
"space_card.not_found": "Space “{id}” not found",
"space_card.loading": "Loading…",
"continuity.restore_plan": "Restoring the plan…",
"continuity.restore_connection": "Restoring the connection to devices…",
"continuity.retry": "Try again",
"editor.space": "Space",
"editor.show_button": "Show button",
"editor.button_label": "Button label",
+3
View File
@@ -342,6 +342,9 @@
"space_card.button": "Перейти к пространству",
"space_card.not_found": "Пространство «{id}» не найдено",
"space_card.loading": "Загрузка…",
"continuity.restore_plan": "Восстанавливаем план…",
"continuity.restore_connection": "Восстанавливаем подключение к устройствам…",
"continuity.retry": "Повторить",
"editor.space": "Пространство",
"editor.show_button": "Показывать кнопку",
"editor.button_label": "Текст кнопки",
+10 -4
View File
@@ -115,9 +115,9 @@ const rayHit = (
* Region lit by a point source, as a single ring in plan coordinates.
*
* `segments` may contain anything: only the ones that can reach into the
* radius are considered. A source sitting exactly on an opaque edge would make
* the sweep degenerate, so such edges are dropped — the caller is expected to
* reject a lamp placed inside masonry before asking.
* radius are considered. A source on an opaque edge is invalid and returns no
* lit region. Dropping that edge would make the wall disappear precisely at a
* grid-snapped placement and illuminate the room on its other side.
*/
export function visibilityPolygon(
source: readonly number[],
@@ -131,7 +131,8 @@ export function visibilityPolygon(
if (!seg || seg.length < 4) continue;
if (![seg[0], seg[1], seg[2], seg[3]].every(Number.isFinite)) continue;
const distance = distanceToSegment(source, seg);
if (distance > radius || distance < 1e-7) continue;
if (distance < 1e-7) return [];
if (distance > radius) continue;
near.push(seg);
}
const angles: number[] = [];
@@ -143,6 +144,11 @@ export function visibilityPolygon(
angles.push(angle - CORNER_NUDGE, angle, angle + CORNER_NUDGE);
}
}
// atan2's seam is a geometric non-event. Normalising every ray onto one
// cyclic interval keeps the +nudge ray beside its corner instead of sorting
// it to the opposite end and closing the fan with a long chord.
const turn = Math.PI * 2;
for (let i = 0; i < angles.length; i++) angles[i] = ((angles[i] % turn) + turn) % turn;
angles.sort((left, right) => left - right);
const ring: number[][] = [];
let previous = Number.NEGATIVE_INFINITY;
-134
View File
@@ -1716,117 +1716,6 @@ export function glowColorOf(state: any, fallback: string): ResolvedGlowValues |
return resolveGlowAppearance(state, null, fallback);
}
/**
* Bound a doorway spill to the part of the source radius that remains after
* the aperture. `reach` is deliberately aperture-relative: callers use it for
* the spill gradient, while `outerRadius` remains source-relative for the
* sight-line sector built by `doorSector`.
*/
export function doorSpillRange(
sourceRadius: number,
apertureEdgeDistance: number,
requestedReach: number,
): { reach: number; outerRadius: number } | null {
if (!Number.isFinite(sourceRadius) || sourceRadius <= 0
|| !Number.isFinite(apertureEdgeDistance) || apertureEdgeDistance < 0
|| !Number.isFinite(requestedReach) || requestedReach <= 0
|| apertureEdgeDistance >= sourceRadius) return null;
const reach = Math.min(requestedReach, sourceRadius - apertureEdgeDistance);
if (!(reach > 1e-6)) return null;
return {
reach,
outerRadius: Math.min(sourceRadius, apertureEdgeDistance + reach),
};
}
/**
* Light spilling through a doorway: the sector of the glow circle between the
* rays source→A and source→B (door edge points), out to radius r.
*
* With `tunnelDepth > 0`, A/B are the centreline opening corners and the wall
* is an actual rectangular tunnel. A valid ray must cross BOTH its source-side
* and far-side spans, so the outgoing angle is the intersection of the two
* spans' angular intervals. That is the shadow cast by the two jamb returns;
* an off-centre lamp no longer shines through the solid corner of a thick wall.
* Zero depth is byte-for-byte the old centreline sector.
*
* This part of the circle is intentionally NOT clipped by the room (owner's
* spec: no general shadow casting — only the opening tunnel constrains it).
* Null when the door is out of reach/source is on an edge, or when the two
* tunnel apertures have no common sight line. Sweep is clamped to maxDeg.
*/
export function doorSector(
src: number[], a: number[], b: number[], r: number, maxDeg = 170,
tunnelDepth = 0,
): number[][] | null {
const la = Math.hypot(a[0] - src[0], a[1] - src[1]);
const lb = Math.hypot(b[0] - src[0], b[1] - src[1]);
if (la < 1e-6 || lb < 1e-6 || Math.min(la, lb) >= r) return null;
let aa: number;
let sweep: number;
if (Number.isFinite(tunnelDepth) && tunnelDepth > 1e-6) {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy);
if (len < 1e-6) return null;
const ux = dx / len, uy = dy / len;
const nx = -uy, ny = ux;
const cx = (a[0] + b[0]) / 2, cy = (a[1] + b[1]) / 2;
const side = ((src[0] - cx) * nx + (src[1] - cy) * ny) >= 0 ? 1 : -1;
const off = tunnelDepth / 2;
const nearA = [a[0] + nx * off * side, a[1] + ny * off * side];
const nearB = [b[0] + nx * off * side, b[1] + ny * off * side];
const farA = [a[0] - nx * off * side, a[1] - ny * off * side];
const farB = [b[0] - nx * off * side, b[1] - ny * off * side];
const base = Math.atan2(cy - src[1], cx - src[0]);
const unwrap = (p: number[]) => {
let ang = Math.atan2(p[1] - src[1], p[0] - src[0]);
while (ang - base > Math.PI) ang -= 2 * Math.PI;
while (ang - base < -Math.PI) ang += 2 * Math.PI;
return ang;
};
const na = unwrap(nearA), nb = unwrap(nearB);
const fa = unwrap(farA), fb = unwrap(farB);
const lo = Math.max(Math.min(na, nb), Math.min(fa, fb));
const hi = Math.min(Math.max(na, nb), Math.max(fa, fb));
if (!(hi - lo > 1e-9)) return null;
aa = lo;
sweep = hi - lo;
} else {
aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
while (sweep > Math.PI) sweep -= 2 * Math.PI;
while (sweep < -Math.PI) sweep += 2 * Math.PI;
}
const max = (maxDeg * Math.PI) / 180;
if (Math.abs(sweep) > max) {
const mid = aa + sweep / 2;
sweep = max * Math.sign(sweep);
aa = mid - sweep / 2;
}
const steps = 8;
const pts: number[][] = [[src[0], src[1]]];
for (let i = 0; i <= steps; i++) {
const ang = aa + (sweep * i) / steps;
pts.push([src[0] + Math.cos(ang) * r, src[1] + Math.sin(ang) * r]);
}
return pts;
}
/**
* Is there a room on the far side of an opening (relative to the light source)?
* Entrance doors lead outside — light must not spill there.
*/
export function hasRoomBehind(
center: number[], angleDeg: number, src: number[], polys: number[][][], probe: number,
): boolean {
const rad = (angleDeg * Math.PI) / 180;
const n = [-Math.sin(rad), Math.cos(rad)];
const toSrc = (src[0] - center[0]) * n[0] + (src[1] - center[1]) * n[1];
const sgn = toSrc > 0 ? -1 : 1;
const p = [center[0] + n[0] * probe * sgn, center[1] + n[1] * probe * sgn];
return polys.some((poly) => pointStrictlyInside(p, poly, 1e-9));
}
/**
* Group toggle for a switch's controlled entities, HA-group semantics:
* any target on -> turn everything off; all off -> turn everything on.
@@ -1877,29 +1766,6 @@ export function sharedBoundary(a: number[][], b: number[][], eps = 1e-6): number
return res;
}
/**
* Connected component of rooms joined by open (virtual) boundaries — the
* "open zone" light flows through. The open_to link counts in either
* direction. Returns a set of room ids including the start.
*/
export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: string[] | null }[]): Set<string> {
const zone = new Set<string>([roomId]);
const linked = (x: any, y: any) =>
(x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id);
let grew = true;
while (grew) {
grew = false;
for (const r of rooms) {
if (!r.id || zone.has(r.id)) continue;
for (const z of rooms) {
if (!z.id || !zone.has(z.id)) continue;
if (linked(r, z)) { zone.add(r.id); grew = true; break; }
}
}
}
return zone;
}
/**
* Segments with the given collinear stretches removed — the workhorse behind
* TRUE dashed open boundaries (derived walls and room outlines alike).
+14 -79
View File
@@ -114,15 +114,18 @@ const ringPath = (poly: number[][]): string =>
`M ${poly.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
/**
* One `d` per resulting polygon (its outer ring plus its holes), never one
* merged string. Two separate paths cannot cancel each other, while a single
* path with the default nonzero rule silently erases the overlap of two
* subpaths wound the opposite way — the way ten joined shadow quads once
* collapsed into nothing.
* One `d` fragment per resulting polygon (its outer ring plus its holes).
* Callers may keep the fragments as separate paths or join them into one `d`
* only with an explicit `evenodd` rule. Relying on default nonzero winding
* can erase oppositely wound subpaths.
*/
export function geometryPolygonPaths(geom: any): string[] {
const out: string[] = [];
for (const poly of geom || []) {
// Polyclip can leave zero-area needles when a visibility fan merely
// touches a floor boundary. Rendering those makes an outside source leak
// a few bright hairlines into the plan; they are not visible floor.
if (geometryArea([poly]) <= 1e-6) continue;
const parts: string[] = [];
for (const ring of poly || []) {
const pts = (ring || []).filter((p: any) => Array.isArray(p) && p.length >= 2);
@@ -134,39 +137,20 @@ export function geometryPolygonPaths(geom: any): string[] {
return out;
}
/** Union of overlapping polygons as disjoint paths: no pixel is painted twice. */
export function unionPaths(polygons: number[][][]): string[] {
const usable = polygons.filter((poly) => poly.length >= 3);
if (!usable.length) return [];
const geom = unionBodies(usable);
return geom ? geometryPolygonPaths(geom) : usable.map(ringPath);
}
/** `polygons` clipped to `bounds`, as disjoint paths. Empty when they miss. */
export function intersectionPaths(polygons: number[][][], bounds: number[][][]): string[] {
const base = unionBodies(polygons.filter((poly) => poly.length >= 3));
const limit = unionBodies(bounds.filter((poly) => poly.length >= 3));
if (!base) return [];
if (!limit) return geometryPolygonPaths(base);
if (!limit) return [];
try {
return geometryPolygonPaths(intersection(base, limit));
} catch {
return geometryPolygonPaths(base);
}
}
/** `polygons` minus `subtract`, as disjoint paths. Falls back to the union. */
export function differencePaths(polygons: number[][][], subtract: number[][][]): string[] {
const usable = polygons.filter((poly) => poly.length >= 3);
if (!usable.length) return [];
const base = unionBodies(usable);
if (!base) return usable.map(ringPath);
const cut = unionBodies(subtract.filter((poly) => poly.length >= 3));
if (!cut) return geometryPolygonPaths(base);
try {
return geometryPolygonPaths(difference(base, cut));
} catch {
return geometryPolygonPaths(base);
// The un-clipped visibility fan may cover the backdrop and the area
// outside the house. A boolean failure must therefore fail dark: returning
// `base` here turns a numerical polyclip exception into a light leak and
// then persists it in the per-source clip cache.
return [];
}
}
@@ -260,55 +244,6 @@ export function directionalOccluders(
])).filter((p) => p.length >= 3);
}
/** Approximate the hard shadow cast by each body away from a point source. */
export function radialOccluders(
bodies: number[][][], source: number[], radius: number,
): number[][][] {
if (!(radius > 0)) return [];
const boundaryEps = Math.max(1e-9, radius * 1e-9);
const onBoundary = (body: number[][]): boolean => body.some((a, i) => {
const b = body[(i + 1) % body.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > 0)) return Math.hypot(source[0] - a[0], source[1] - a[1]) <= boundaryEps;
const t = Math.max(0, Math.min(1,
((source[0] - a[0]) * dx + (source[1] - a[1]) * dy) / len2));
return Math.hypot(source[0] - (a[0] + t * dx), source[1] - (a[1] + t * dy))
<= boundaryEps;
});
// A misplaced source inside/on masonry is invalid input. Cover its entire
// pool so it cannot illuminate through the body in any direction.
if (bodies.some((body) => pointInPhysicalBody(source, body) || onBoundary(body))) {
return [Array.from({ length: 128 }, (_, i) => {
const a = (i / 128) * Math.PI * 2;
return [source[0] + Math.cos(a) * radius * 1.01,
source[1] + Math.sin(a) * radius * 1.01];
})];
}
const out: number[][][] = [];
for (const body of bodies) {
if (body.length < 2) continue;
out.push(body);
// One quad per edge. Its far chord is deliberately pushed beyond the
// glow circle even for an edge subtending almost 180°; a single hull with
// a fixed projection distance lets that chord cut back through the pool.
for (let i = 0; i < body.length; i++) {
const a = body[i], b = body[(i + 1) % body.length];
const ax = a[0] - source[0], ay = a[1] - source[1];
const bx = b[0] - source[0], by = b[1] - source[1];
const da = Math.hypot(ax, ay), db = Math.hypot(bx, by);
if (!(da > boundaryEps) || !(db > boundaryEps)) continue;
const cosTheta = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (da * db)));
const halfCos = Math.sqrt(Math.max(0, (1 + cosTheta) / 2));
const far = Math.max(radius * 2, (radius * 1.02) / Math.max(halfCos, 1e-3));
const pa = [source[0] + (ax / da) * far, source[1] + (ay / da) * far];
const pb = [source[0] + (bx / db) * far, source[1] + (by / db) * far];
out.push([a, b, pb, pa]);
}
}
return out;
}
export function pointInPhysicalBody(point: number[], body: number[][]): boolean {
let inside = false;
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
+272 -34
View File
@@ -6,6 +6,44 @@ export const SIGN_INFLIGHT_MS = 15000;
export const SIGN_BACKOFF_MIN_MS = 2000;
export const SIGN_BACKOFF_MAX_MS = 60000;
type SharedSignedEntry = {
url: string;
at: number;
loaded: boolean;
/** A refresh is decoded off-DOM before it may replace the visible url. */
pending?: { url: string; at: number };
preload?: Promise<boolean>;
};
type SharedSignerRuntime = {
signed: Record<string, SharedSignedEntry>;
queued: Set<string>;
inFlight: Map<string, number>;
retry: Map<string, { notBefore: number; delay: number }>;
listeners: Set<() => void>;
references: Map<object, Set<string>>;
};
const sharedSignerRuntimes = new WeakMap<object, SharedSignerRuntime>();
const SHARED_SIGNED_MAX = 96;
const signerAuthority = (hass: any, fallback: object): object => {
const authority = hass?.connection || hass;
return authority && (typeof authority === 'object' || typeof authority === 'function')
? authority : fallback;
};
const signerRuntime = (authority: object): SharedSignerRuntime => {
let runtime = sharedSignerRuntimes.get(authority);
if (!runtime) {
runtime = {
signed: {}, queued: new Set(), inFlight: new Map(), retry: new Map(),
listeners: new Set(), references: new Map(),
};
sharedSignerRuntimes.set(authority, runtime);
}
return runtime;
};
/**
* Signed urls for the authenticated content endpoint, shared by both cards.
*
@@ -32,16 +70,19 @@ export const SIGN_BACKOFF_MAX_MS = 60000;
* forever, and a failure backs off instead of retrying on the next frame.
*/
export class ContentSigner {
private signed: Record<string, { url: string; at: number }> = {};
private readonly fallbackAuthority = {};
private readonly referenceOwner = {};
private shared?: SharedSignerRuntime;
private authority?: object;
private referenced = new Set<string>();
/** Waiting for the batch timer to fire. */
private queued = new Set<string>();
/** Sent and not settled yet: url -> when the request went out. */
private inFlight = new Map<string, number>();
/** After a failure: when this url may be asked again, and the current delay. */
private retry = new Map<string, { notBefore: number; delay: number }>();
private batchTimer?: ReturnType<typeof setTimeout>;
private resignTimer?: ReturnType<typeof setInterval>;
private disposed = false;
private readonly sharedUpdate = () => {
if (!this.disposed) this.onUpdate();
};
/**
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
@@ -49,9 +90,29 @@ export class ContentSigner {
*/
constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
private bind(hass: any): SharedSignerRuntime {
const authority = signerAuthority(hass, this.fallbackAuthority);
if (this.shared && this.authority === authority) return this.shared;
if (this.shared) {
this.shared.listeners.delete(this.sharedUpdate);
this.shared.references.delete(this.referenceOwner);
}
this.authority = authority;
this.shared = signerRuntime(authority);
if (!this.disposed) {
this.shared.listeners.add(this.sharedUpdate);
this.shared.references.set(this.referenceOwner, new Set(this.referenced));
}
return this.shared;
}
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
start(hass: () => any, referenced: () => Set<string>): void {
this.disposed = false; // an element can be reconnected after a disconnect
this.referenced = new Set(referenced());
const shared = this.bind(hass());
shared.listeners.add(this.sharedUpdate);
shared.references.set(this.referenceOwner, new Set(this.referenced));
this.stopTimer();
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
}
@@ -61,8 +122,12 @@ export class ContentSigner {
this.disposed = true;
this.stopTimer();
clearTimeout(this.batchTimer);
if (this.shared) {
this.shared.listeners.delete(this.sharedUpdate);
this.shared.references.delete(this.referenceOwner);
for (const url of this.queued) this.shared.queued.delete(url);
}
this.queued.clear();
this.inFlight.clear();
}
private stopTimer(): void {
@@ -74,15 +139,18 @@ export class ContentSigner {
display(hass: any, url: string | null | undefined): string {
const u = contentUrl(url);
if (!u.startsWith('/api/houseplan/content/')) return u;
const hit = this.signed[u];
const shared = this.bind(hass);
this.referenced.add(u);
shared.references.set(this.referenceOwner, new Set(this.referenced));
const hit = shared.signed[u];
const age = hit ? this.now() - hit.at : Infinity;
if (age < SIGN_REFRESH_MS) return hit.url;
if (age < SIGN_TTL_MS) {
// aging but still valid: keep showing it while a fresh one is fetched
this.request(hass, u);
if (!hit.pending) this.request(hass, u);
return hit.url;
}
if (hit) delete this.signed[u];
if (hit) delete shared.signed[u];
this.request(hass, u);
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
// returns 401 and Home Assistant raises a "failed login attempt".
@@ -91,69 +159,211 @@ export class ContentSigner {
private request(hass: any, url: string): void {
if (!hass?.callWS || this.queued.has(url)) return;
const shared = this.bind(hass);
const now = this.now();
const sent = this.inFlight.get(url);
const sent = shared.inFlight.get(url);
if (sent !== undefined && now - sent < SIGN_INFLIGHT_MS) return; // already asking
const back = this.retry.get(url);
if (shared.queued.has(url)) return;
const back = shared.retry.get(url);
if (back && now < back.notBefore) return; // still backing off
this.queued.add(url);
shared.queued.add(url);
clearTimeout(this.batchTimer);
// batch: switching space asks for several urls in the same tick
this.batchTimer = setTimeout(() => {
const paths = [...this.queued];
this.queued.clear();
for (const path of paths) shared.queued.delete(path);
this.sign(hass, paths);
}, 30);
}
private sign(hass: any, paths: string[]): void {
if (!paths.length || !hass?.callWS) return;
const shared = this.bind(hass);
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
const sentAt = this.now();
for (const p of batch) this.inFlight.set(p, sentAt);
const claimed = batch.filter((p) => {
const sent = shared.inFlight.get(p);
if (sent !== undefined && sentAt - sent < SIGN_INFLIGHT_MS) return false;
shared.inFlight.set(p, sentAt);
return true;
});
if (!claimed.length) continue;
hass
.callWS({ type: 'houseplan/content/sign', paths: batch })
.callWS({ type: 'houseplan/content/sign', paths: claimed })
.then((r: any) => {
if (this.disposed) return;
// A successful call does NOT mean every path was signed: the backend
// skips a path it cannot sign, logs it and still answers `{urls: …}`
// with the rest. Treating the whole batch as done then cleared the
// backoff for the missing ones, so every later render asked again —
// the very amplification the backoff exists to stop (review R5-1).
const at = this.now();
const next = { ...this.signed };
let accepted = 0;
for (const p of batch) {
let notifyNow = false;
for (const p of claimed) {
const url = r?.urls?.[p]; // only keys we asked for
if (typeof url === 'string' && url) {
next[p] = { url, at };
this.retry.delete(p);
const previous = shared.signed[p];
if (previous?.loaded && previous.url !== url) {
// Stale-while-decode: never swap an already painted backdrop
// for a network/decode candidate. The old signed URL remains
// in every card until the replacement is ready to paint.
previous.pending = { url, at };
this.preloadReplacement(shared, p, url, at);
} else {
shared.signed[p] = {
url,
at,
loaded: previous?.url === url ? !!previous.loaded : false,
};
notifyNow = true;
}
shared.retry.delete(p);
accepted++;
} else {
this.backOff(p);
this.backOff(shared, p);
}
}
if (!accepted) return;
this.signed = next;
this.onUpdate();
this.trimShared(shared);
if (notifyNow) for (const listener of [...shared.listeners]) listener();
})
.catch(() => {
// back off rather than retry on the very next frame: a socket that
// is refusing sign requests would otherwise be hammered per render
for (const p of batch) this.backOff(p);
for (const p of claimed) this.backOff(shared, p);
})
.finally(() => {
// release only our own attempt: a later one may have superseded it
for (const p of batch) if (this.inFlight.get(p) === sentAt) this.inFlight.delete(p);
for (const p of claimed) if (shared.inFlight.get(p) === sentAt) shared.inFlight.delete(p);
});
}
}
private preloadReplacement(
shared: SharedSignerRuntime,
path: string,
url: string,
at: number,
): void {
const promote = (): void => {
const current = shared.signed[path];
if (current?.pending?.url !== url) return;
shared.signed[path] = { url, at, loaded: true };
shared.retry.delete(path);
this.trimShared(shared);
for (const listener of [...shared.listeners]) listener();
};
const reject = (): void => {
const current = shared.signed[path];
if (current?.pending?.url !== url) return;
delete current.pending;
this.backOff(shared, path);
};
// Unit/non-DOM runtimes cannot preload; accepting synchronously preserves
// the cache contract while browsers take the stricter decode path.
if (typeof Image === 'undefined') {
promote();
return;
}
const image = new Image();
image.onload = () => {
const decoded = typeof image.decode === 'function' ? image.decode() : Promise.resolve();
decoded.then(promote).catch(reject);
};
image.onerror = reject;
image.src = url;
}
private preloadCurrentImage(
shared: SharedSignerRuntime,
path: string,
entry: SharedSignedEntry,
): Promise<boolean> {
if (entry.loaded) return Promise.resolve(true);
if (entry.preload) return entry.preload;
if (typeof Image === 'undefined') {
entry.loaded = true;
return Promise.resolve(true);
}
const url = entry.url;
entry.preload = new Promise<boolean>((resolve) => {
const image = new Image();
const finish = (ready: boolean): void => {
const current = shared.signed[path];
if (current === entry) delete current.preload;
if (ready && current === entry && current.url === url) {
current.loaded = true;
for (const listener of [...shared.listeners]) listener();
resolve(true);
return;
}
if (!ready && current === entry) this.backOff(shared, path);
resolve(false);
};
image.onload = () => {
const decoded = typeof image.decode === 'function' ? image.decode() : Promise.resolve();
decoded.then(() => finish(true)).catch(() => finish(false));
};
image.onerror = () => finish(false);
image.src = url;
});
return entry.preload;
}
/**
* Sign and decode a protected image before a structural candidate adopts it.
* The current frame can therefore keep its old backdrop for the whole wait.
*/
prepareImage(hass: any, url: string | null | undefined): Promise<boolean> {
const path = contentUrl(url);
if (!path.startsWith('/api/houseplan/content/')) return Promise.resolve(true);
const shared = this.bind(hass);
const attempt = (): Promise<boolean> | null => {
const entry = shared.signed[path];
if (entry && this.now() - entry.at < SIGN_TTL_MS) {
return this.preloadCurrentImage(shared, path, entry);
}
this.display(hass, path);
return null;
};
const immediate = attempt();
if (immediate) return immediate;
return new Promise<boolean>((resolve) => {
let done = false;
let active: Promise<boolean> | null = null;
const finish = (ready: boolean): void => {
if (done) return;
done = true;
clearTimeout(timer);
shared.listeners.delete(check);
resolve(ready);
};
const check = (): void => {
if (done || active) return;
active = attempt();
if (!active) return;
active.then(finish).catch(() => finish(false));
};
const timer = setTimeout(() => finish(false), SIGN_INFLIGHT_MS);
shared.listeners.add(check);
check();
});
}
/** Next attempt for this url waits, and each failure waits twice as long. */
private backOff(url: string): void {
const prev = this.retry.get(url)?.delay || 0;
private backOff(shared: SharedSignerRuntime, url: string): void {
const prev = shared.retry.get(url)?.delay || 0;
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
this.retry.set(url, { notBefore: this.now() + delay, delay });
shared.retry.set(url, { notBefore: this.now() + delay, delay });
}
private trimShared(shared: SharedSignerRuntime): void {
const entries = Object.entries(shared.signed);
if (entries.length <= SHARED_SIGNED_MAX) return;
entries.sort((left, right) => right[1].at - left[1].at);
shared.signed = Object.fromEntries(entries.slice(0, SHARED_SIGNED_MAX));
}
/**
@@ -162,23 +372,51 @@ export class ContentSigner {
* request — so prune to the urls the live config still references.
*/
resign(hass: any, referenced: Set<string>): void {
const shared = this.bind(hass);
this.referenced = new Set(referenced);
shared.references.set(this.referenceOwner, new Set(this.referenced));
const now = this.now();
const kept: Record<string, { url: string; at: number }> = {};
for (const [k, v] of Object.entries(this.signed)) {
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
const allReferenced = new Set<string>();
for (const refs of shared.references.values()) for (const path of refs) allReferenced.add(path);
const kept: Record<string, SharedSignedEntry> = {};
for (const [k, v] of Object.entries(shared.signed)) {
if (allReferenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
}
this.signed = kept;
this.retry.clear(); // a scheduled refresh is a fresh chance for everything
this.sign(hass, Object.keys(kept));
shared.signed = kept;
shared.retry.clear(); // a scheduled refresh is a fresh chance for everything
this.sign(hass, [...referenced].filter((path) => !!kept[path] && !kept[path].pending));
}
/** Mark a protected image as decoded/paintable; shared warm mounts inherit it. */
markLoaded(hass: any, url: string | null | undefined): void {
const path = contentUrl(url);
if (!path.startsWith('/api/houseplan/content/')) return;
const entry = this.bind(hass).signed[path];
if (entry && !entry.loaded) {
entry.loaded = true;
for (const listener of [...this.bind(hass).listeners]) listener();
}
}
/** Required protected assets are ready only after signing and image load. */
isReady(hass: any, url: string | null | undefined): boolean {
const path = contentUrl(url);
if (!path.startsWith('/api/houseplan/content/')) return true;
const entry = this.bind(hass).signed[path];
return !!entry && this.now() - entry.at < SIGN_TTL_MS && entry.loaded;
}
/** Test/debug view of the cache. */
get entries(): Record<string, { url: string; at: number }> {
return this.signed;
const entries: Record<string, { url: string; at: number }> = {};
for (const [path, entry] of Object.entries(this.shared?.signed || {})) {
entries[path] = { url: entry.url, at: entry.at };
}
return entries;
}
/** Test/debug view of what is currently being asked for. */
get inFlightUrls(): string[] {
return [...this.inFlight.keys()];
return [...(this.shared?.inFlight.keys() || [])];
}
}
+312 -28
View File
@@ -19,6 +19,13 @@ import {
type PresentationActivityRuntime,
} from './device-presentation';
import type { DevItem } from './types';
import {
VisualContinuityController,
contentFingerprint,
subscribePageVisibility,
visualFrameFingerprint,
type PageVisibilitySignal,
} from './visual-continuity';
import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
@@ -55,14 +62,28 @@ class HouseplanSpaceCard extends LitElement {
private _config?: SpaceCardConfig;
private _snap: HpConfigSnapshot | null = null;
private _loading = false;
private _reloadQueued = false;
private _forceReloadQueued = false;
private _reloadRetryTimer = 0;
private _unsub?: () => void;
private _stageWidth = 0;
private _pendingStageWidth = 0;
private _stageWidthRaf = 0;
private _stageObserver?: ResizeObserver;
private _observedStage?: HTMLElement;
private _haRegistryRelease?: () => void;
private _haRegistryConnection: any = null;
private _haRegistryRevision = -1;
private _devices: DevItem[] = [];
private _continuity = this._newContinuityController();
private _continuityUnsub?: () => void;
private _continuityEpoch = 0;
private _continuityDataReady = true;
private _continuityPaintToken = -1;
private _continuityDisposed = false;
private _renderSnapshotAt = Date.now();
private _hassSequence = 0;
private _connHooked: any = null;
private _activityRuntime = new Map<string, StaticActivityRuntime>();
private _onHaRegistryUpdate = () => {
const revision = haRegistrySnapshot(this.hass).revision;
@@ -85,8 +106,48 @@ class HouseplanSpaceCard extends LitElement {
hass: { attribute: false },
_config: { state: true },
_snap: { state: true },
_continuityEpoch: { state: true },
};
private _newContinuityController(): VisualContinuityController {
return new VisualContinuityController(() => {
this._continuityEpoch++;
if (this.isConnected) this.requestUpdate();
});
}
private _pageVisibility = (signal: PageVisibilitySignal): void => {
const token = this._continuity.visibility(signal);
if (signal.kind === 'hidden' || !signal.long) return;
if (Date.now() - this._renderSnapshotAt > 1000) this._continuity.note('device-snapshot-stale');
this._continuityDataReady = false;
this._continuityPaintToken = -1;
if (token === this._continuity.token) void this._load(true);
};
private _onConnLost = (): void => {
this._continuityDataReady = false;
this._continuityPaintToken = -1;
this._continuity.connectionLost();
};
private _onConnReady = (): void => {
this._beginContinuityCandidate('connection-ready', false, 'connection');
void this._load(true);
};
private _hookConnection(): void {
const connection = this.hass?.connection;
if (!connection || connection === this._connHooked) return;
this._connHooked?.removeEventListener?.('ready', this._onConnReady);
this._connHooked?.removeEventListener?.('disconnected', this._onConnLost);
this._connHooked?.removeEventListener?.('reconnect-error', this._onConnLost);
connection.addEventListener?.('ready', this._onConnReady);
connection.addEventListener?.('disconnected', this._onConnLost);
connection.addEventListener?.('reconnect-error', this._onConnLost);
this._connHooked = connection;
}
public static getConfigElement() {
return document.createElement('houseplan-space-card-editor');
}
@@ -114,38 +175,67 @@ class HouseplanSpaceCard extends LitElement {
}
public connectedCallback(): void {
if (this._continuityDisposed) {
this._continuity = this._newContinuityController();
this._continuityDisposed = false;
this._continuityPaintToken = -1;
}
super.connectedCallback();
if (this.hass) this._ensureHaRegistryAuthority();
this._continuityUnsub?.();
this._continuityUnsub = subscribePageVisibility(this.ownerDocument, this._pageVisibility);
this._unsub = onConfigChange(() => {
this._loading = false;
this._snap = null;
this.requestUpdate();
this._beginContinuityCandidate('config-event', false);
this._reloadQueued = true;
void this._load();
});
// a dashboard on a wall tablet outlives a 24 h signature
this._signer.start(() => this.hass, () => this._referenced());
}
public disconnectedCallback(): void {
this._continuityUnsub?.();
this._continuityUnsub = undefined;
this._unsub?.();
this._unsub = undefined;
window.clearTimeout(this._reloadRetryTimer);
this._reloadRetryTimer = 0;
this._stageObserver?.disconnect();
this._stageObserver = undefined;
this._observedStage = undefined;
if (this._stageWidthRaf) cancelAnimationFrame(this._stageWidthRaf);
this._stageWidthRaf = 0;
this._pendingStageWidth = 0;
this._signer.dispose();
this._haRegistryRelease?.();
this._haRegistryRelease = undefined;
this._haRegistryConnection = null;
this._connHooked?.removeEventListener?.('ready', this._onConnReady);
this._connHooked?.removeEventListener?.('disconnected', this._onConnLost);
this._connHooked?.removeEventListener?.('reconnect-error', this._onConnLost);
this._connHooked = null;
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
this._activityRuntime.clear();
this._continuity.dispose();
this._continuityDisposed = true;
super.disconnectedCallback();
}
protected willUpdate(changed: PropertyValues): void {
if (changed.has('hass') && this.hass) this._ensureHaRegistryAuthority();
if (changed.has('hass') && this.hass) {
this._hassSequence++;
this._renderSnapshotAt = Date.now();
this._continuity.note('hass-snapshot');
this._ensureHaRegistryAuthority();
this._hookConnection();
}
if (this.hass && !this._loading && (!this._snap || changed.has('hass'))) {
if (!this._snap || !this._loadedOnce) this._load();
}
this._refreshDevices();
if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
this._continuity.refreshCompleteFrame(this._frameFingerprint());
}
}
private _stampActivity(runtime: StaticActivityRuntime, kind: 'event' | 'transition'): void {
@@ -223,40 +313,188 @@ class HouseplanSpaceCard extends LitElement {
this._devices = devices;
}
protected updated(): void {
const stage = this.renderRoot.querySelector<HTMLElement>('.hp-static-stage') || undefined;
if (stage === this._observedStage) return;
this._stageObserver?.disconnect();
this._observedStage = stage;
if (!stage) {
this._stageObserver = undefined;
private _beginContinuityCandidate(
reason: string,
dataReady: boolean,
recoveryReason: 'plan' | 'connection' | 'stage-size' | 'asset' = 'plan',
): number {
this._continuityDataReady = dataReady;
this._continuityPaintToken = -1;
return this._continuity.beginCandidate(reason, recoveryReason);
}
private _backdropRaw(): string {
if (!this._snap?.config || !this._config) return '';
return spaceModels(this._snap.config).find((space) => space.id === this._config!.space)?.bg?.href || '';
}
private _candidateBackdrop(config: any): string {
if (!config || !this._config) return '';
return spaceModels(config).find((space) => space.id === this._config!.space)?.bg?.href || '';
}
private _assetsReady(): boolean {
const raw = this._backdropRaw();
return !raw || this._signer.isReady(this.hass, raw);
}
private _frameFingerprint(): string {
const snap = this._snap;
return visualFrameFingerprint([
snap?.rev || 0,
snap?.configFingerprint || contentFingerprint(snap?.config),
snap?.layoutRev || 0,
snap?.layoutFingerprint || contentFingerprint(snap?.layout),
this._config?.space || '',
this._stageWidth,
this._hassSequence,
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
]);
}
private _stageValid(): boolean {
const stage = this._observedStage;
return !!stage && stage.clientWidth > 0 && stage.clientHeight > 0;
}
private _settleContinuityFrame(): void {
if (!this._stageValid()) return;
if (!this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
if (!this._assetsReady()) {
this._beginContinuityCandidate('asset-wait', true, 'asset');
return;
}
this._renderSnapshotAt = Date.now();
this._continuity.markCompleteFrame(this._frameFingerprint());
return;
}
const measure = () => {
const width = stage.clientWidth;
if (width > 0 && Math.abs(width - this._stageWidth) > 0.5) {
this._stageWidth = width;
this.requestUpdate();
if (!this._continuityDataReady || !this._assetsReady()) return;
if (!['holding', 'offline-stale', 'overlay-pending', 'overlay-visible', 'candidate-ready']
.includes(this._continuity.state)) return;
const token = this._continuity.token;
if (this._continuityPaintToken === token) return;
this._continuityPaintToken = token;
if (!this._continuity.candidateReady(token)) return;
void this._continuity.commitAfterPaint(token, {
updateComplete: () => this.updateComplete,
stageValid: () => this.isConnected && this._stageValid(),
assetsReady: () => this._assetsReady(),
frameFingerprint: () => this._frameFingerprint(),
}).then((committed) => {
if (!committed || token !== this._continuity.token) {
if (token === this._continuity.token) this._continuityPaintToken = -1;
return;
}
};
this._stageObserver = new ResizeObserver(measure);
this._stageObserver.observe(stage);
measure();
this._renderSnapshotAt = Date.now();
});
}
private _onAssetLoaded = (raw: string): void => {
this._signer.markLoaded(this.hass, raw);
this._continuity.note('asset-ready');
this._continuityPaintToken = -1;
if (this._continuity.state !== 'steady') this.requestUpdate();
};
protected updated(): void {
const stage = this.renderRoot.querySelector<HTMLElement>('.hp-static-stage') || undefined;
if (stage !== this._observedStage) {
this._stageObserver?.disconnect();
this._observedStage = stage;
if (!stage) {
this._stageObserver = undefined;
} else {
const measure = () => {
const width = stage.clientWidth;
if (width <= 0 || Math.abs(width - this._stageWidth) <= 0.5) return;
if (this._stageWidth <= 0) {
this._stageWidth = width;
this.requestUpdate();
return;
}
this._pendingStageWidth = width;
if (this._stageWidthRaf) return;
this._stageWidthRaf = requestAnimationFrame(() => {
this._stageWidthRaf = 0;
const target = this._pendingStageWidth;
this._pendingStageWidth = 0;
if (!target || !this._observedStage || this._observedStage.clientWidth <= 0) return;
if (Math.abs(this._observedStage.clientWidth - target) > 0.5) {
measure();
return;
}
if (Math.abs(target - this._stageWidth) <= 0.5) return;
if (this._continuity.hasCompleteFrame) {
this._beginContinuityCandidate('stage-resize', true, 'stage-size');
}
this._stageWidth = target;
this.requestUpdate();
});
};
this._stageObserver = new ResizeObserver(measure);
this._stageObserver.observe(stage);
measure();
}
}
this._settleContinuityFrame();
}
private _loadedOnce = false;
private async _load(): Promise<void> {
if (!this.hass || this._loading) return;
private async _load(force = false): Promise<void> {
if (!this.hass) return;
if (this._loading) {
this._reloadQueued = true;
this._forceReloadQueued ||= force;
return;
}
this._loading = true;
this._reloadQueued = false;
let loaded = false;
try {
const snap = await getConfig(this.hass);
this._snap = snap;
const snap = await getConfig(this.hass, force);
const configChanged = !this._snap
|| this._snap.configFingerprint !== snap.configFingerprint;
const layoutChanged = !this._snap
|| this._snap.layoutFingerprint !== snap.layoutFingerprint;
if (configChanged && !await this._signer.prepareImage(
this.hass, this._candidateBackdrop(snap.config),
)) {
this._continuity.note('asset-failed');
window.clearTimeout(this._reloadRetryTimer);
this._reloadRetryTimer = window.setTimeout(() => void this._load(true), 1000);
return;
}
window.clearTimeout(this._reloadRetryTimer);
this._reloadRetryTimer = 0;
if ((configChanged || layoutChanged) && this._continuity.hasCompleteFrame
&& this._continuity.state === 'steady') {
this._beginContinuityCandidate('structural-response', true);
}
if (configChanged || layoutChanged) {
this._snap = snap;
} else if (this._snap) {
// Revision-only echoes are metadata, not a new visual candidate.
this._snap.rev = snap.rev;
this._snap.layoutRev = snap.layoutRev;
}
if (configChanged) this._continuity.note('config-candidate', { configRev: snap.rev });
if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: snap.layoutRev });
this._loadedOnce = true;
loaded = true;
this._continuityDataReady = true;
this._refreshDevices();
} catch {
/* keep any localStorage snapshot */
} finally {
this._loading = false;
if (!loaded && !this._continuity.hasCompleteFrame) this._continuityDataReady = true;
this.requestUpdate();
if (this._reloadQueued) {
const forceAgain = this._forceReloadQueued;
this._reloadQueued = false;
this._forceReloadQueued = false;
void this._load(forceAgain);
}
}
}
@@ -275,7 +513,14 @@ class HouseplanSpaceCard extends LitElement {
}
private _errorCard(msg: string): TemplateResult {
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
return html`<ha-card
data-continuity-state=${this._continuity.state}
data-continuity-token=${this._continuity.token}
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
data-recovery-reason=${(this._continuity.overlayVisible || this._continuity.state === 'recovery-error')
? this._continuity.recoveryReason || nothing : nothing}>
<div class="hp-static-error">${msg}</div>
</ha-card>`;
}
/**
@@ -290,12 +535,38 @@ class HouseplanSpaceCard extends LitElement {
return referencedContentUrls(this._snap?.config);
}
private _retryContinuity = (): void => {
this._continuityDataReady = false;
this._continuityPaintToken = -1;
this._continuity.retry(this._continuity.recoveryReason || 'plan');
void this._load(true);
};
private _renderRecoveryOverlay(): TemplateResult | typeof nothing {
if (!this._continuity.overlayVisible && this._continuity.state !== 'recovery-error') return nothing;
const connection = this._continuity.recoveryReason === 'connection';
return html`<div class="recoveryoverlay phase-${this._continuity.overlayPhase}"
role="status" aria-live="polite" aria-atomic="true">
<ha-icon icon="mdi:home-sync-outline"></ha-icon>
<span>${t(this._lang, connection
? 'continuity.restore_connection' : 'continuity.restore_plan')}</span>
${this._continuity.state === 'recovery-error'
? html`<button class="btn on" @click=${this._retryContinuity}>${t(this._lang, 'continuity.retry')}</button>`
: nothing}
</div>`;
}
/** Redacted lifecycle diagnostics shared with the full card's sampler. */
public houseplanContinuityTrace(): readonly import('./visual-continuity').ContinuityTraceEvent[] {
return this._continuity.trace;
}
protected render(): TemplateResult | typeof nothing {
if (!this._config) return nothing;
const cfg = this._snap?.config;
if (!cfg) {
// still loading and no snapshot yet
return html`<ha-card><div class="hp-static-error">${t(this._lang, 'space_card.loading')}</div></ha-card>`;
return this._errorCard(t(this._lang, 'space_card.loading'));
}
const spaceId = this._config.space;
const stage = renderSpaceStatic({
@@ -308,6 +579,7 @@ class HouseplanSpaceCard extends LitElement {
lang: this._lang,
// resolved at render time: a url baked in earlier would be the unsigned one
displayUrl: (raw) => this._signer.display(this.hass, raw),
assetLoaded: this._onAssetLoaded,
registry: haRegistrySnapshot(this.hass),
devices: this._devices,
activityRuntime: this._activityRuntime,
@@ -322,10 +594,19 @@ class HouseplanSpaceCard extends LitElement {
const title = this._config.title !== undefined ? this._config.title : sp?.title || '';
const showButton = this._config.show_button !== false;
const label = this._config.button_label || t(this._lang, 'space_card.button');
const recoveryReason = (this._continuity.overlayVisible || this._continuity.state === 'recovery-error')
? this._continuity.recoveryReason : null;
return html`
<ha-card>
<ha-card
data-continuity-state=${this._continuity.state}
data-continuity-token=${this._continuity.token}
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
data-recovery-reason=${recoveryReason || nothing}>
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
${stage}
<div class="hp-static-body">
${stage}
${this._renderRecoveryOverlay()}
</div>
${showButton
? html`<div class="hp-static-foot">
<button class="hp-static-btn" @click=${this._goToSpace}>${label}</button>
@@ -349,6 +630,9 @@ class HouseplanSpaceCard extends LitElement {
font-size: 16px;
color: var(--primary-text-color);
}
.hp-static-body {
position: relative;
}
.hp-static-stage {
position: relative;
width: 100%;
+3
View File
@@ -56,6 +56,8 @@ export interface StaticRenderOpts {
* rather than an unsigned one, which would 401 (review R3-2).
*/
displayUrl?: (raw: string) => string;
/** Marks a protected backdrop as loaded/paintable for continuity barriers. */
assetLoaded?: (raw: string) => void;
}
export interface StaticDeviceBuildOpts {
@@ -309,6 +311,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
)}
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}"
@load=${() => o.assetLoaded?.(space.bg!.href)}
transform=${space.bg!.angle
? `rotate(${space.bg!.angle} ${space.bg!.x + space.bg!.w / 2} ${space.bg!.y + space.bg!.h / 2})`
: nothing}
+50 -23
View File
@@ -282,16 +282,6 @@ export const cardStyles = css`
.stage.hpboot .zoombadge {
visibility: hidden;
}
/* A long-suspended normal View wakes through transient HA/viewport
geometry. Keep those few frames behind the already-correct stage
background; the final viewport and reveal are committed together.
This class is never set in kiosk or an editor. */
.stage.hpresume .zoomwrap,
.stage.hpresume .zoombadge,
.stage.hpresume .farhint,
.stage.hpresume .homearrow {
visibility: hidden;
}
/* AUD-1552-02: post-veil grace — HA chrome landing after the cap moves
the stage height smoothly; the viewport ResizeObserver refits the plan
along the transition, so a late panel glides instead of jumping. */
@@ -321,6 +311,38 @@ export const cardStyles = css`
opacity: 0.85;
animation: hp-boot-pulse 1.3s ease-in-out infinite;
}
.recoveryoverlay {
position: absolute;
inset: 0;
/* Above the contextual editor tray (70): recovery is the one state in
which every stage-editing surface must be inert. Dialogs live outside
the stage and retain their own higher card-level stacking context. */
z-index: 75;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-4);
padding: var(--sp-6);
box-sizing: border-box;
background: var(--ha-card-background, var(--card-background-color, #111));
color: var(--primary-text-color, #fff);
text-align: center;
pointer-events: auto;
transition: opacity 0.15s ease;
}
.recoveryoverlay.phase-entering,
.recoveryoverlay.phase-leaving {
opacity: 0;
}
.recoveryoverlay.phase-fading-in,
.recoveryoverlay.phase-opaque {
opacity: 1;
}
.recoveryoverlay ha-icon {
--mdc-icon-size: 44px;
color: var(--hp-accent);
}
@keyframes hp-boot-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.94); }
50% { opacity: 0.9; transform: scale(1); }
@@ -330,6 +352,9 @@ export const cardStyles = css`
animation: none;
opacity: 0.7;
}
.recoveryoverlay {
transition: none;
}
.stage.hpsettle {
transition: none;
}
@@ -498,8 +523,7 @@ export const cardStyles = css`
.glow-base-tunnels,
.glow-pools-frame,
.glow-pools,
.glow-spot,
.glow-soft {
.glow-spot {
pointer-events: none;
}
/* The parent isolates all source spots from the room data fill, Glow base,
@@ -1344,12 +1368,14 @@ export const cardStyles = css`
stroke: rgba(62, 166, 255, 0.55);
stroke-opacity: 1;
}
/* View hover follows the clean-floor face above the unioned wall body.
Plain fill/strokes deliberately replace CSS filters: GPU promotion of a
filtered SVG sibling used to flash the screen-blended Glow layer. */
/* The wash is rendered directly above the resolved room fill but below
tunnels, Glow, sun and walls. Black at 22% reproduces the old
brightness(.78) contract without filtering/promoting an SVG ancestor or
changing the room's hue. The halo/outline are rendered again late, above
the wall bodies. */
.room-hover-fill {
fill: var(--hp-accent);
fill-opacity: 0.10;
fill: #000;
fill-opacity: 0.22;
pointer-events: none;
}
.room-hover-halo {
@@ -1681,22 +1707,23 @@ export const cardStyles = css`
}
.stage.mode-devices .dev { cursor: grab; }
.stage.mode-devices .dev:active { cursor: grabbing; }
.dev:hover {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
z-index: 5;
}
.dev.on {
background: var(--hp-on);
border-color: var(--hp-on);
color: #503c00;
box-shadow: 0 0 8px rgba(255, 212, 92, 0.7);
}
.dev.open {
background: var(--hp-open);
border-color: var(--hp-open);
color: #4a2800;
}
/* Interaction wins ordinary state colours. Alarm keeps priority through
the more-specific .dev.alarm:hover rule above. */
.dev:hover {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
z-index: 5;
}
.dev.unavail {
opacity: 0.35;
}
+473
View File
@@ -0,0 +1,473 @@
/**
* Shared visual-continuity primitives for both House Plan cards (#73).
*
* The controller deliberately owns no House Plan model and never calls HA.
* It only guards the transition from the last complete frame to a candidate,
* keeps recovery-overlay timing deterministic, and exposes a small trace for
* lifecycle diagnostics.
*/
export const CONTINUITY_LONG_HIDDEN_MS = 15_000;
export const RECOVERY_OVERLAY_DELAY_MS = 150;
export const RECOVERY_OVERLAY_FADE_IN_MS = 150;
export const RECOVERY_OVERLAY_MIN_OPAQUE_MS = 250;
export const RECOVERY_OVERLAY_FADE_OUT_MS = 180;
export const PAINT_BARRIER_MAX_MS = 2_000;
export type ContinuityState =
| 'steady'
| 'holding'
| 'candidate-ready'
| 'overlay-pending'
| 'overlay-visible'
| 'offline-stale'
| 'recovery-error';
export type RecoveryReason = 'plan' | 'connection' | 'stage-size' | 'asset' | null;
export type OverlayPhase = 'none' | 'entering' | 'fading-in' | 'opaque' | 'leaving';
export interface ContinuityTraceEvent {
at: number;
token: number;
event: string;
state: ContinuityState;
reason?: string;
stage?: [number, number];
configRev?: number;
layoutRev?: number;
assetPending?: number;
}
export interface PageVisibilitySignal {
kind: 'hidden' | 'visible' | 'pageshow';
token: number;
at: number;
hiddenFor: number;
long: boolean;
persisted?: boolean;
}
interface PageVisibilityRuntime {
hiddenAt: number;
token: number;
subscribers: Set<(signal: PageVisibilitySignal) => void>;
onVisibility: () => void;
onPageShow: (event: PageTransitionEvent) => void;
}
const visibilityRuntimes = new WeakMap<Document, PageVisibilityRuntime>();
/** One visibility/pageshow listener per Document, shared by every card. */
export function subscribePageVisibility(
doc: Document,
subscriber: (signal: PageVisibilitySignal) => void,
): () => void {
let runtime = visibilityRuntimes.get(doc);
if (!runtime) {
const subscribers = new Set<(signal: PageVisibilitySignal) => void>();
runtime = {
hiddenAt: doc.visibilityState === 'hidden' ? Date.now() : 0,
token: 0,
subscribers,
onVisibility: () => {
const current = visibilityRuntimes.get(doc);
if (!current) return;
const at = Date.now();
if (doc.visibilityState === 'hidden') {
if (!current.hiddenAt) current.hiddenAt = at;
const signal: PageVisibilitySignal = {
kind: 'hidden', token: current.token, at, hiddenFor: 0, long: false,
};
for (const listener of [...current.subscribers]) listener(signal);
return;
}
const hiddenFor = current.hiddenAt ? Math.max(0, at - current.hiddenAt) : 0;
current.hiddenAt = 0;
current.token++;
const signal: PageVisibilitySignal = {
kind: 'visible', token: current.token, at, hiddenFor,
long: hiddenFor >= CONTINUITY_LONG_HIDDEN_MS,
};
for (const listener of [...current.subscribers]) listener(signal);
},
onPageShow: (event: PageTransitionEvent) => {
const current = visibilityRuntimes.get(doc);
if (!current) return;
const at = Date.now();
current.token++;
const signal: PageVisibilitySignal = {
kind: 'pageshow', token: current.token, at, hiddenFor: 0,
long: !!event.persisted, persisted: !!event.persisted,
};
for (const listener of [...current.subscribers]) listener(signal);
},
};
visibilityRuntimes.set(doc, runtime);
doc.addEventListener('visibilitychange', runtime.onVisibility);
doc.defaultView?.addEventListener('pageshow', runtime.onPageShow);
}
runtime.subscribers.add(subscriber);
return () => {
const current = visibilityRuntimes.get(doc);
if (!current) return;
current.subscribers.delete(subscriber);
if (current.subscribers.size) return;
doc.removeEventListener('visibilitychange', current.onVisibility);
doc.defaultView?.removeEventListener('pageshow', current.onPageShow);
visibilityRuntimes.delete(doc);
};
}
export interface ContinuityClock {
now(): number;
setTimeout(callback: () => void, delay: number): number;
clearTimeout(id: number): void;
requestAnimationFrame(callback: FrameRequestCallback): number;
cancelAnimationFrame(id: number): void;
}
const browserClock: ContinuityClock = {
now: () => Date.now(),
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
clearTimeout: (id) => window.clearTimeout(id),
requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),
cancelAnimationFrame: (id) => window.cancelAnimationFrame(id),
};
export interface PaintBarrierHooks {
updateComplete(): Promise<unknown>;
stageValid(): boolean;
assetsReady(): boolean;
frameFingerprint(): string;
}
/** A bounded, placement-owned state machine; business data stays in the card. */
export class VisualContinuityController {
private _state: ContinuityState = 'steady';
private _token = 0;
private _frameFingerprint = '';
private _hasCompleteFrame = false;
private _overlayPhase: OverlayPhase = 'none';
private _recoveryReason: RecoveryReason = null;
private _overlayTimer = 0;
private _overlayRaf = 0;
private _overlayOpaqueAt = 0;
private _barrierRafs = new Set<number>();
private _trace: ContinuityTraceEvent[] = [];
private _disposed = false;
constructor(
private readonly onChange: () => void,
private readonly clock: ContinuityClock = browserClock,
) {}
get state(): ContinuityState { return this._state; }
get token(): number { return this._token; }
get frameFingerprint(): string { return this._frameFingerprint; }
get hasCompleteFrame(): boolean { return this._hasCompleteFrame; }
get overlayPhase(): OverlayPhase { return this._overlayPhase; }
get overlayVisible(): boolean { return this._overlayPhase !== 'none'; }
get overlayBlocksInteraction(): boolean { return this.overlayVisible; }
get recoveryReason(): RecoveryReason { return this._recoveryReason; }
get trace(): readonly ContinuityTraceEvent[] { return this._trace; }
/** Redacted card-owned lifecycle markers; never pass ids or urls here. */
note(event: string, detail: Partial<ContinuityTraceEvent> = {}): void {
this.record(event, undefined, detail);
}
private record(event: string, reason?: string, detail: Partial<ContinuityTraceEvent> = {}): void {
this._trace.push({
at: this.clock.now(), token: this._token, event, state: this._state,
...(reason ? { reason } : {}), ...detail,
});
if (this._trace.length > 80) this._trace.splice(0, this._trace.length - 80);
}
private changed(): void {
if (!this._disposed) this.onChange();
}
/** Warm memo/static cache adoption happens before the first visible render. */
adoptCompleteFrame(fingerprint: string): void {
if (!fingerprint) return;
this._hasCompleteFrame = true;
this._frameFingerprint = fingerprint;
this._state = 'steady';
this._recoveryReason = null;
this.clearOverlay();
this.record('frame-adopted');
this.changed();
}
markCompleteFrame(fingerprint: string): void {
if (!fingerprint) return;
this._hasCompleteFrame = true;
this._frameFingerprint = fingerprint;
this._state = 'steady';
this._recoveryReason = null;
this.clearOverlay();
this.record('frame-complete');
this.changed();
}
/**
* State-only renders are already one synchronous Lit commit and do not need
* a structural candidate. Keep diagnostics/warm placement identity current
* without scheduling the extra render that `markCompleteFrame()` uses for a
* cold/adopted frame.
*/
refreshCompleteFrame(fingerprint: string): void {
if (this._disposed || !this._hasCompleteFrame || this._state !== 'steady'
|| !fingerprint || fingerprint === this._frameFingerprint) return;
this._frameFingerprint = fingerprint;
this.record('frame-refreshed');
}
visibility(signal: PageVisibilitySignal): number {
if (signal.kind === 'hidden') {
this.record('visibility-hidden');
return this._token;
}
if (!signal.long && (signal.kind === 'visible' || signal.kind === 'pageshow')) {
// A quick return with no changed inputs is a strict no-op: no Lit update.
this.record(signal.kind === 'pageshow' ? 'pageshow-noop' : 'visibility-visible-quick');
return this._token;
}
return this.beginCandidate(signal.kind === 'pageshow' ? 'pageshow' : 'long-resume');
}
beginCandidate(reason: string, recoveryReason: RecoveryReason = 'plan'): number {
this._token++;
this._recoveryReason = recoveryReason;
if (this._hasCompleteFrame) {
this._state = recoveryReason === 'connection' ? 'offline-stale' : 'holding';
this.clearOverlay();
} else if (this.overlayVisible) {
// Retry/late reconnect keeps an already opaque fallback in place; it may
// only leave after the new token crosses the paint barrier.
this._state = 'overlay-visible';
} else {
this._state = 'overlay-pending';
this.scheduleOverlay(recoveryReason);
}
this.record('candidate-start', reason);
this.changed();
return this._token;
}
connectionLost(): number {
return this.beginCandidate('connection-lost', 'connection');
}
candidateReady(token: number): boolean {
if (this._disposed || token !== this._token) return false;
this._state = 'candidate-ready';
this.record('candidate-ready');
this.changed();
return true;
}
/**
* Lit completion + two animation opportunities form one cancellable paint
* barrier. A stale token can never reveal an older candidate.
*/
async commitAfterPaint(token: number, hooks: PaintBarrierHooks): Promise<boolean> {
if (this._disposed || token !== this._token) return false;
const barrier = (async () => {
await hooks.updateComplete();
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) return false;
await this.nextFrame();
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) return false;
await this.nextFrame();
return token === this._token && hooks.stageValid() && hooks.assetsReady();
})();
let timeout = 0;
const timed = new Promise<{ ready: false; timedOut: true }>((resolve) => {
timeout = this.clock.setTimeout(
() => resolve({ ready: false, timedOut: true }), PAINT_BARRIER_MAX_MS,
);
});
const outcome = await Promise.race([
barrier.then((ready) => ({ ready, timedOut: false as const })),
timed,
]);
this.clock.clearTimeout(timeout);
if (this._disposed || token !== this._token) return false;
if (!outcome.ready && !outcome.timedOut) {
// Stage/resource readiness can change between the two paint checks.
// This is a recoverable rejection, not the two-second timeout: retain
// the token and let the card retry when the missing readiness signal
// arrives, without converting a transient image decode into an error.
this.record('paint-barrier-rejected');
if (this._hasCompleteFrame) {
this._state = this._recoveryReason === 'connection' ? 'offline-stale' : 'holding';
} else {
this._state = this.overlayVisible ? 'overlay-visible' : 'overlay-pending';
}
this.changed();
return false;
}
if (!outcome.ready) {
this.record('paint-barrier-timeout');
if (this._hasCompleteFrame) {
this._state = this._recoveryReason === 'connection' ? 'offline-stale' : 'steady';
this.clearOverlay();
} else {
this._state = 'recovery-error';
if (this._overlayPhase === 'none') this.showOverlayNow();
}
this.changed();
return false;
}
this._hasCompleteFrame = true;
this._frameFingerprint = hooks.frameFingerprint();
this.record('paint-barrier');
this.finishOverlayAfterCommit();
return true;
}
retry(reason: RecoveryReason = this._recoveryReason || 'plan'): number {
return this.beginCandidate('retry', reason);
}
private nextFrame(): Promise<void> {
return new Promise((resolve) => {
const id = this.clock.requestAnimationFrame(() => {
this._barrierRafs.delete(id);
resolve();
});
this._barrierRafs.add(id);
});
}
private scheduleOverlay(reason: RecoveryReason): void {
this.clearOverlayTimer();
this._recoveryReason = reason;
this._overlayTimer = this.clock.setTimeout(() => {
this._overlayTimer = 0;
if (this._hasCompleteFrame || this._state !== 'overlay-pending') return;
this._state = 'overlay-visible';
this._overlayPhase = 'entering';
this.record('overlay-enter');
this.changed();
this._overlayRaf = this.clock.requestAnimationFrame(() => {
this._overlayRaf = 0;
if (this._overlayPhase !== 'entering') return;
this._overlayPhase = 'fading-in';
this.changed();
this._overlayTimer = this.clock.setTimeout(() => {
this._overlayTimer = 0;
if (this._overlayPhase !== 'fading-in') return;
this._overlayPhase = 'opaque';
this._overlayOpaqueAt = this.clock.now();
this.record('overlay-opaque');
this.changed();
}, RECOVERY_OVERLAY_FADE_IN_MS);
});
}, RECOVERY_OVERLAY_DELAY_MS);
}
private showOverlayNow(): void {
this.clearOverlayTimer();
this._overlayPhase = 'opaque';
this._overlayOpaqueAt = this.clock.now();
this.record('overlay-error');
}
private finishOverlayAfterCommit(): void {
this.clearOverlayTimer();
if (this._overlayPhase === 'none' || this._overlayPhase === 'entering'
|| this._overlayPhase === 'fading-in') {
// A candidate that wins during fade-in cancels it without a minimum hold.
this.clearOverlay();
this._state = 'steady';
this._recoveryReason = null;
this.record('candidate-committed');
this.changed();
return;
}
const wait = Math.max(0,
this._overlayOpaqueAt + RECOVERY_OVERLAY_MIN_OPAQUE_MS - this.clock.now());
this._overlayTimer = this.clock.setTimeout(() => {
this._overlayTimer = 0;
this._overlayPhase = 'leaving';
this.record('overlay-leave');
this.changed();
this._overlayTimer = this.clock.setTimeout(() => {
this._overlayTimer = 0;
this._overlayPhase = 'none';
this._state = 'steady';
this._recoveryReason = null;
this.record('candidate-committed');
this.changed();
}, RECOVERY_OVERLAY_FADE_OUT_MS);
}, wait);
}
private clearOverlayTimer(): void {
if (this._overlayTimer) this.clock.clearTimeout(this._overlayTimer);
this._overlayTimer = 0;
}
private clearOverlay(): void {
this.clearOverlayTimer();
if (this._overlayRaf) this.clock.cancelAnimationFrame(this._overlayRaf);
this._overlayRaf = 0;
this._overlayPhase = 'none';
this._overlayOpaqueAt = 0;
}
dispose(): void {
this._disposed = true;
this._token++;
this.clearOverlay();
for (const id of this._barrierRafs) this.clock.cancelAnimationFrame(id);
this._barrierRafs.clear();
}
}
/** Stable, inexpensive content identity for structural WS responses. */
export function contentFingerprint(value: unknown): string {
let a = 0x811c9dc5;
let b = 0x9e3779b9;
const seen = new WeakSet<object>();
const feed = (text: string): void => {
for (let index = 0; index < text.length; index++) {
const code = text.charCodeAt(index);
a ^= code;
a = Math.imul(a, 0x01000193);
b ^= code + ((b << 6) >>> 0) + (b >>> 2);
}
};
const visit = (node: unknown): void => {
if (node === null) { feed('n'); return; }
const type = typeof node;
if (type === 'string') { feed(`s${(node as string).length}:${node}`); return; }
if (type === 'number') { feed(`d${Number.isNaN(node) ? 'NaN' : String(node)}`); return; }
if (type === 'boolean') { feed(node ? 't' : 'f'); return; }
if (type === 'undefined') { feed('u'); return; }
if (type !== 'object') { feed(`${type}:${String(node)}`); return; }
const object = node as object;
if (seen.has(object)) { feed('[cycle]'); return; }
seen.add(object);
if (Array.isArray(node)) {
feed('[');
for (const item of node) visit(item);
feed(']');
return;
}
feed('{');
for (const key of Object.keys(node as Record<string, unknown>).sort()) {
feed(key);
visit((node as Record<string, unknown>)[key]);
}
feed('}');
};
visit(value);
return `${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`;
}
export function visualFrameFingerprint(parts: readonly unknown[]): string {
return contentFingerprint(parts);
}
+8 -4
View File
@@ -1302,8 +1302,9 @@ export function wallBodyRings(
* with opening slots cut through. Drawing uses it as one path; the light model
* uses the same geometry as its occluders, so a wall blocks light exactly
* where the plan shows a wall — with its real thickness, and with a doorway
* that is a real gap between two jamb faces. Null when there is nothing solid,
* or when the boolean pass fails (callers fall back to their own outlines).
* that is a real gap between two jamb faces. A successful empty operation is
* returned as an empty geometry; null means the boolean pass itself failed,
* so drawing callers may distinguish it from "nothing solid" and fall back.
*/
export function wallBodiesGeometry(
rooms: any[],
@@ -1386,7 +1387,7 @@ export function wallBodiesGeometry(
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
}
return body ? { geom: body, depthUnits: maxDepth } : null;
return { geom: body || [], depthUnits: maxDepth };
} catch {
return null;
}
@@ -1411,11 +1412,14 @@ export function wallBodiesUnionPath(
);
const d = united ? polyclipToPathD(united.geom) : '';
if (united && d) return { d, depthUnits: united.depthUnits, fillRule: 'evenodd' };
if (united) return null; // successful empty result: do not resurrect raw rings
// fall back to evenodd rings concatenated
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
if (!rings.length && !extraD) return null;
let maxDepth = united?.depthUnits || 0;
// `united` is null on this branch: a successful-but-empty union returned
// above and must not be resurrected by the raw-ring fallback.
let maxDepth = 0;
for (const ring of rings) maxDepth = Math.max(maxDepth, ring.depthUnits);
// Each room ring already reverses its inset. `nonzero` therefore keeps
// floors as holes while overlapping independent rings add instead of