mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Release v1.61.0-beta.8 candidate
This commit is contained in:
+29
-6
@@ -23,6 +23,8 @@ export interface HpConfigSnapshot {
|
||||
|
||||
let cache: HpConfigSnapshot | null = null;
|
||||
let inflight: Promise<HpConfigSnapshot> | null = null;
|
||||
let fetchGeneration = 0;
|
||||
let inflightGeneration = -1;
|
||||
let subscribed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -48,12 +50,12 @@ export function cachedSnapshot(): HpConfigSnapshot | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
async function fetchFresh(hass: any, generation: number): Promise<HpConfigSnapshot> {
|
||||
const [cfgResp, layResp] = await Promise.all([
|
||||
hass.callWS({ type: 'houseplan/config/get' }),
|
||||
hass.callWS({ type: 'houseplan/layout/get' }),
|
||||
]);
|
||||
cache = {
|
||||
const snapshot: HpConfigSnapshot = {
|
||||
config: cfgResp?.config ?? null,
|
||||
rev: cfgResp?.rev ?? 0,
|
||||
configFingerprint: contentFingerprint(cfgResp?.config ?? null),
|
||||
@@ -61,10 +63,15 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
layoutRev: layResp?.rev ?? 0,
|
||||
layoutFingerprint: contentFingerprint(layResp?.layout ?? {}),
|
||||
};
|
||||
// A force request may arrive while this fetch is in flight. Its older
|
||||
// response remains useful to its original caller, but must not repopulate
|
||||
// the shared cache after the newer generation invalidated it.
|
||||
if (generation === fetchGeneration) cache = snapshot;
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
const invalidate = () => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
fetchGeneration++;
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
try {
|
||||
@@ -78,16 +85,32 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
subscribed = false;
|
||||
}
|
||||
}
|
||||
return cache;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Get the shared config snapshot (cached, deduped across cards). */
|
||||
export function getConfig(hass: any, force = false): Promise<HpConfigSnapshot> {
|
||||
if (force) cache = null;
|
||||
if (force) {
|
||||
cache = null;
|
||||
fetchGeneration++;
|
||||
if (inflight) {
|
||||
// Do not let `force` silently join the stale request it was meant to
|
||||
// supersede. Wait for that transport slot, then issue a fresh generation.
|
||||
return inflight.catch(() => null).then(() => getConfig(hass, false));
|
||||
}
|
||||
}
|
||||
if (cache) return Promise.resolve(cache);
|
||||
if (inflight) return inflight;
|
||||
inflight = fetchFresh(hass).finally(() => {
|
||||
if (inflight) {
|
||||
if (inflightGeneration !== fetchGeneration) {
|
||||
return inflight.catch(() => null).then(() => getConfig(hass, false));
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
const generation = fetchGeneration;
|
||||
inflightGeneration = generation;
|
||||
inflight = fetchFresh(hass, generation).finally(() => {
|
||||
inflight = null;
|
||||
inflightGeneration = -1;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
+288
-70
@@ -142,6 +142,9 @@ import {
|
||||
presentationClasses, presentationSourceSignature, resolveDevicePresentation,
|
||||
resolvePresentationSources, type ResolvedDevicePresentation,
|
||||
} from './device-presentation';
|
||||
import {
|
||||
createRenderDeviceSnapshot, presentationSnapshotKey, type RenderDeviceSnapshot,
|
||||
} from './render-device-snapshot';
|
||||
import { deviceFaceStyle, renderDeviceFace } from './device-face';
|
||||
import {
|
||||
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
|
||||
@@ -160,7 +163,7 @@ import {
|
||||
import { renderOpeningTunnelFills } from './render/opening-tunnels';
|
||||
import { safeStoredColor } from './color';
|
||||
|
||||
const CARD_VERSION = '1.61.0-beta.7';
|
||||
const CARD_VERSION = '1.61.0-beta.8';
|
||||
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
|
||||
const DECOR_TEXT_CM_MAX = 2000;
|
||||
const CELL_CM_MIN = 0.1;
|
||||
@@ -1011,6 +1014,8 @@ class HouseplanCard extends LitElement {
|
||||
* First open only; kiosk is 100dvh and never jumps. */
|
||||
private _booting = true;
|
||||
private _bootFading = false; // veil kept one beat for the opacity-out
|
||||
private _bootSettling = false;
|
||||
private _bootSettleRaf = 0;
|
||||
private _bootTimer?: number;
|
||||
private _bootLastH = -1;
|
||||
private _bootStart = 0;
|
||||
@@ -1197,6 +1202,7 @@ class HouseplanCard extends LitElement {
|
||||
private _unsubTrail?: () => void;
|
||||
private _vacJumpOnce = false;
|
||||
private _continuity = this._newContinuityController();
|
||||
private _continuityHistory: import('./visual-continuity').ContinuityTraceEvent[] = [];
|
||||
private _continuityUnsub?: () => void;
|
||||
private _continuityEpoch = 0;
|
||||
private _continuityDataReady = true;
|
||||
@@ -1204,6 +1210,14 @@ class HouseplanCard extends LitElement {
|
||||
private _continuityDisposed = false;
|
||||
private _renderSnapshotAt = Date.now();
|
||||
private _hassSequence = 0;
|
||||
private _visibleDeviceSnapshot: RenderDeviceSnapshot | null = null;
|
||||
private _candidateDeviceSnapshot: RenderDeviceSnapshot | null = null;
|
||||
private _stagedDeviceSnapshotToken = -1;
|
||||
private _capturedSnapshotSequence = -1;
|
||||
private _capturedSnapshotDevices: DevItem[] | null = null;
|
||||
private _capturedSnapshotLayout: Record<string, { x: number; y: number; s?: string; k?: number }> | null = null;
|
||||
private _capturedSnapshotActivity = '';
|
||||
private _capturedSnapshotConfigEpoch = -1;
|
||||
private _lastValidStageSize: [number, number] | null = null;
|
||||
private _pendingRefitSize: [number, number] | null = null;
|
||||
private _refitRaf = 0;
|
||||
@@ -1217,7 +1231,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _pageVisibility = (signal: PageVisibilitySignal): void => {
|
||||
const token = this._continuity.visibility(signal);
|
||||
this._continuity.visibility(signal);
|
||||
if (signal.kind === 'hidden') return;
|
||||
this._vacJumpOnce = true;
|
||||
if (!signal.long) return; // strict quick-return no-op: preserve hover and DOM
|
||||
@@ -1229,10 +1243,8 @@ class HouseplanCard extends LitElement {
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
this._resumeSettling = true;
|
||||
if (token === this._continuity.token) {
|
||||
if (!this._loading) void this._loadFromServer();
|
||||
else this.requestUpdate();
|
||||
}
|
||||
if (!this._loading) void this._loadFromServer();
|
||||
else this.requestUpdate();
|
||||
};
|
||||
private _resumeSettling = false;
|
||||
private _viewportInvalidAt = 0;
|
||||
@@ -1429,6 +1441,8 @@ class HouseplanCard extends LitElement {
|
||||
if (this._vacRaf) { cancelAnimationFrame(this._vacRaf); this._vacRaf = 0; }
|
||||
if (this._refitRaf) { cancelAnimationFrame(this._refitRaf); this._refitRaf = 0; }
|
||||
if (this._skySnapRaf) { cancelAnimationFrame(this._skySnapRaf); this._skySnapRaf = 0; }
|
||||
if (this._bootSettleRaf) { cancelAnimationFrame(this._bootSettleRaf); this._bootSettleRaf = 0; }
|
||||
this._bootSettling = false;
|
||||
for (const rt of this._activityRt.values()) clearTimeout(rt.timer); // pending activity-window repaints
|
||||
window.removeEventListener('keydown', this._keyHandler);
|
||||
clearInterval(this._cycleTimer);
|
||||
@@ -1519,6 +1533,7 @@ class HouseplanCard extends LitElement {
|
||||
// after the disconnect snapshot (which must still skip unstable geometry),
|
||||
// so a same-element reattach can start fresh instead of keeping the veil.
|
||||
this._resumeSettling = false;
|
||||
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
|
||||
this._continuity.dispose();
|
||||
this._continuityDisposed = true;
|
||||
super.disconnectedCallback();
|
||||
@@ -2223,6 +2238,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._booting && !this._continuity.hasCompleteFrame) return this._continuity.token;
|
||||
this._continuityDataReady = dataReady;
|
||||
this._continuityPaintToken = -1;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
this._resumeSettling = true;
|
||||
return this._continuity.beginCandidate(reason, recoveryReason);
|
||||
}
|
||||
@@ -2233,6 +2249,10 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _continuityAssetsReady(): boolean {
|
||||
// An empty/transient model is not a complete plan frame. Returning true
|
||||
// here used to let the previous DOM be blessed while the replacement
|
||||
// config was still being projected.
|
||||
if (!this._model.length) return false;
|
||||
const space = this._model.length ? this._spaceModel() : null;
|
||||
return !space?.bg?.href || this._signer.isReady(this.hass, space.bg.href);
|
||||
}
|
||||
@@ -2260,7 +2280,6 @@ class HouseplanCard extends LitElement {
|
||||
this._view,
|
||||
size,
|
||||
this._glowScreenBlend ? 'screen' : 'normal',
|
||||
this._hassSequence,
|
||||
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
|
||||
]);
|
||||
}
|
||||
@@ -2269,18 +2288,25 @@ class HouseplanCard extends LitElement {
|
||||
private _settleContinuityFrame(): void {
|
||||
if (this._booting || !this._continuityStageValid()) return;
|
||||
if (!this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
if (!this._continuityAssetsReady()) {
|
||||
this._beginContinuityCandidate('asset-wait', true, 'asset');
|
||||
return;
|
||||
// Cold start belongs to the boot veil. Do not replace it with a recovery
|
||||
// error just because a protected backdrop is still signing/decoding.
|
||||
if (this._continuityAssetsReady()) {
|
||||
this._renderSnapshotAt = Date.now();
|
||||
this._continuity.markCompleteFrame(this._visualFrameFingerprint());
|
||||
}
|
||||
this._renderSnapshotAt = Date.now();
|
||||
this._continuity.markCompleteFrame(this._visualFrameFingerprint());
|
||||
return;
|
||||
}
|
||||
if (!this._continuityDataReady || !this._continuityAssetsReady()) return;
|
||||
if (!this._continuityDataReady) return;
|
||||
if (!['holding', 'offline-stale', 'overlay-pending', 'overlay-visible', 'candidate-ready']
|
||||
.includes(this._continuity.state)) return;
|
||||
const token = this._continuity.token;
|
||||
if (this._candidateDeviceSnapshot
|
||||
&& this._candidateDeviceSnapshot !== this._visibleDeviceSnapshot
|
||||
&& this._stagedDeviceSnapshotToken !== token) {
|
||||
this._stagedDeviceSnapshotToken = token;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (this._continuityPaintToken === token) return;
|
||||
this._continuityPaintToken = token;
|
||||
if (!this._continuity.candidateReady(token)) return;
|
||||
@@ -2291,17 +2317,25 @@ class HouseplanCard extends LitElement {
|
||||
frameFingerprint: () => this._visualFrameFingerprint(),
|
||||
}).then((committed) => {
|
||||
if (!committed || token !== this._continuity.token) {
|
||||
if (token === this._continuity.token) this._continuityPaintToken = -1;
|
||||
if (token === this._continuity.token) {
|
||||
this._continuityPaintToken = -1;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._resumeSettling = false;
|
||||
this._renderSnapshotAt = Date.now();
|
||||
if (this._candidateDeviceSnapshot) this._visibleDeviceSnapshot = this._candidateDeviceSnapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
this._warmSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
private _onBackdropLoaded(raw: string): void {
|
||||
this._signer.markLoaded(this.hass, raw);
|
||||
private _onBackdropLoaded(raw: string, paintedUrl?: string): void {
|
||||
this._signer.markLoaded(this.hass, raw, paintedUrl);
|
||||
this._continuity.note('asset-ready');
|
||||
this._continuityPaintToken = -1;
|
||||
if (this._continuity.state !== 'steady') this.requestUpdate();
|
||||
@@ -2332,7 +2366,8 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Redacted lifecycle diagnostics used by the deterministic sampler. */
|
||||
public houseplanContinuityTrace(): readonly import('./visual-continuity').ContinuityTraceEvent[] {
|
||||
return this._continuity.trace;
|
||||
return [...this._continuityHistory, ...this._continuity.trace].slice(-80)
|
||||
.map((event) => ({ ...event, ...(event.stage ? { stage: [...event.stage] as [number, number] } : {}) }));
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
@@ -2522,7 +2557,6 @@ class HouseplanCard extends LitElement {
|
||||
// invariant local to that reactive assignment so imports, reconnects and
|
||||
// demo harnesses cannot accidentally reuse an older config object's data.
|
||||
if (changed.has('_serverCfg')) this._cfgEpoch++;
|
||||
this._skyPlan();
|
||||
if (changed.has('hass') && this.hass) {
|
||||
this._hassSequence++;
|
||||
this._renderSnapshotAt = Date.now();
|
||||
@@ -2542,6 +2576,11 @@ class HouseplanCard extends LitElement {
|
||||
if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
this._continuity.refreshCompleteFrame(this._visualFrameFingerprint());
|
||||
}
|
||||
this._captureRenderDeviceSnapshot();
|
||||
// The sky is part of the same atomic frame as sun rays and devices. Plan
|
||||
// it only after the candidate/visible render snapshot has been refreshed;
|
||||
// doing this first made day/night presentation lag every HA tick by one.
|
||||
this._skyPlan();
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
@@ -2692,6 +2731,7 @@ class HouseplanCard extends LitElement {
|
||||
this._beginContinuityCandidate('structural-response', true);
|
||||
}
|
||||
this._loadOk = true;
|
||||
this._connectionWasLost = false;
|
||||
this._serverStorage = true;
|
||||
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
|
||||
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
|
||||
@@ -2778,11 +2818,14 @@ class HouseplanCard extends LitElement {
|
||||
// next hass update (WS warm-up)
|
||||
} finally {
|
||||
this._loading = false;
|
||||
// Readiness belongs to the candidate attempt, including a bounded asset
|
||||
// failure. Leaving it false on an early return stranded the controller
|
||||
// before its own two-second barrier could ever start.
|
||||
this._continuityDataReady = true;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
}
|
||||
this._continuityDataReady = true;
|
||||
this._regSignature = '';
|
||||
this._maybeRebuildDevices();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2864,6 +2907,7 @@ class HouseplanCard extends LitElement {
|
||||
* down would never run again. 'ready' fires on every (re)connect: reset
|
||||
* the budget and quietly re-read the config. */
|
||||
private _connHooked: { removeEventListener?: (t: string, cb: () => void) => void } | null = null;
|
||||
private _connectionWasLost = false;
|
||||
/** Shared, page-level full HA registry authority (one fetch/subscription per connection). */
|
||||
private _haRegistryRelease?: () => void;
|
||||
private _haRegistryConnection: any = null;
|
||||
@@ -2910,6 +2954,131 @@ class HouseplanCard extends LitElement {
|
||||
return active;
|
||||
}
|
||||
|
||||
private _captureRenderDeviceSnapshot(): void {
|
||||
if (!this.hass) return;
|
||||
const now = Date.now();
|
||||
const activity = [...this._activityRt.entries()]
|
||||
.map(([id, runtime]) => `${id}:${runtime.gen}:${runtime.flashTs}:`
|
||||
+ `${runtime.flashTs > 0 && now - runtime.flashTs <= ACTIVITY_WINDOW_MS ? 1 : 0}`)
|
||||
.join('|');
|
||||
if (this._capturedSnapshotSequence === this._hassSequence
|
||||
&& this._capturedSnapshotDevices === this._devices
|
||||
&& this._capturedSnapshotLayout === this._layout
|
||||
&& this._capturedSnapshotConfigEpoch === this._cfgEpoch
|
||||
&& this._capturedSnapshotActivity === activity) return;
|
||||
const planHass = this._planHass;
|
||||
const presentations = new Map<string, ResolvedDevicePresentation>();
|
||||
const facts = new Map<string, unknown>();
|
||||
const entityIds = new Set<string>(['sun.sun']);
|
||||
if (this._vacFit?.source) entityIds.add(this._vacFit.source);
|
||||
const deviceIds = new Set<string>();
|
||||
const areaIds = new Set<string>();
|
||||
const addSource = (source: string | null | undefined): void => {
|
||||
if (!source) return;
|
||||
const separator = source.indexOf(':');
|
||||
if (separator < 0) { entityIds.add(source); return; }
|
||||
const kind = source.slice(0, separator), reference = source.slice(separator + 1);
|
||||
if (kind === 'device') deviceIds.add(reference);
|
||||
else if (kind === 'entity') entityIds.add(reference);
|
||||
};
|
||||
for (const space of this._model) for (const room of space.rooms) {
|
||||
if (room.area) areaIds.add(room.area);
|
||||
addSource(room.settings?.temp_source);
|
||||
addSource(room.settings?.hum_source);
|
||||
}
|
||||
for (const rawSpace of this._serverCfg?.spaces || []) for (const opening of rawSpace.openings || []) {
|
||||
if (opening.contact) entityIds.add(opening.contact);
|
||||
if (opening.lock) entityIds.add(opening.lock);
|
||||
}
|
||||
// Inline HA variables on the decorative layer are part of the painted
|
||||
// frame too. Capture both the current token format and the legacy
|
||||
// one-entity fields so a reconnect cannot update a label ahead of the
|
||||
// rooms, devices and light layers around it.
|
||||
for (const rawSpace of this._serverCfg?.spaces || []) {
|
||||
for (const shape of rawSpace.decor || []) {
|
||||
if (shape.kind !== 'text') continue;
|
||||
for (const match of String(shape.text || '').matchAll(/\{([^{}\r\n]+)\}/g)) {
|
||||
const reference = liveTextReference(match[1]);
|
||||
if (reference?.entity) entityIds.add(reference.entity);
|
||||
}
|
||||
if (shape.entity) entityIds.add(shape.entity);
|
||||
// HA's formatter functions are runtime capabilities and must not cross
|
||||
// the immutable render-snapshot boundary. Freeze the already formatted
|
||||
// caption instead, so locale/precision/unit and every surrounding plan
|
||||
// layer belong to one atomic HA frame.
|
||||
facts.set(`decor:${rawSpace.id}:${shape.id}`, liveText(
|
||||
shape.text, shape, planHass,
|
||||
(entityId) => !!planHass.entities?.[entityId] && !!planHass.states?.[entityId]
|
||||
&& !isRemovedPlanEntity(planHass, entityId, removedPlanBindings(this._markers)),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (const device of this._devices) {
|
||||
for (const showLqi of [false, true]) {
|
||||
presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation(
|
||||
planHass, device, {
|
||||
liveStates: this._config?.live_states !== false,
|
||||
showTemperature: this._config?.show_temperature !== false,
|
||||
showSignal: showLqi && this._config?.show_signal !== false,
|
||||
activityRuntime: this._activityRt.get(device.id),
|
||||
sourceDetails: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
if (this._isVacDev(device)) {
|
||||
const source = this._vacSource(device, planHass);
|
||||
const telemetry = source ? readVacTelemetry(planHass?.states?.[source]?.attributes) : null;
|
||||
const runtime = this._vacRt.get(device.id);
|
||||
facts.set(`vacuum:${device.id}`, {
|
||||
source,
|
||||
telemetry,
|
||||
mapId: telemetry ? this._vacMapId(device, telemetry, planHass) : null,
|
||||
runtime: runtime ? {
|
||||
trail: runtime.trail,
|
||||
lastTs: runtime.lastTs,
|
||||
moving: runtime.moving,
|
||||
jump: runtime.jump,
|
||||
} : null,
|
||||
server: this._vacSrvTrails[device.id] || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const snapshot = createRenderDeviceSnapshot({
|
||||
sourceSequence: this._hassSequence, hass: planHass,
|
||||
devices: this._devices, presentations,
|
||||
positions: new Map(this._devices.map((device) => [device.id, this._livePos(device)])),
|
||||
facts,
|
||||
entityIds,
|
||||
deviceIds,
|
||||
areaIds,
|
||||
});
|
||||
this._capturedSnapshotSequence = this._hassSequence;
|
||||
this._capturedSnapshotDevices = this._devices;
|
||||
this._capturedSnapshotLayout = this._layout;
|
||||
this._capturedSnapshotActivity = activity;
|
||||
this._capturedSnapshotConfigEpoch = this._cfgEpoch;
|
||||
if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') {
|
||||
this._visibleDeviceSnapshot = snapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
} else {
|
||||
this._candidateDeviceSnapshot = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
private get _renderDeviceSnapshot(): RenderDeviceSnapshot | null {
|
||||
return this._stagedDeviceSnapshotToken === this._continuity.token
|
||||
? this._candidateDeviceSnapshot || this._visibleDeviceSnapshot
|
||||
: this._visibleDeviceSnapshot || this._candidateDeviceSnapshot;
|
||||
}
|
||||
|
||||
private get _renderPlanHass(): any {
|
||||
return this._renderDeviceSnapshot?.hass || this._planHass;
|
||||
}
|
||||
|
||||
private get _renderDevices(): readonly DevItem[] {
|
||||
return this._renderDeviceSnapshot?.devices || this._devices;
|
||||
}
|
||||
|
||||
/** Full registry metadata for dialogs/ghost labels; never use for actions. */
|
||||
private get _fullRegistryHass(): any {
|
||||
void this._planHass;
|
||||
@@ -2990,7 +3159,19 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._loadRetryTimer);
|
||||
this._loadRetryTimer = undefined;
|
||||
refreshHaRegistries(this.hass);
|
||||
this._beginContinuityCandidate('connection-ready', false, 'connection');
|
||||
if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush();
|
||||
if (this._cfgWriting) {
|
||||
clearTimeout(this._reloadRetry);
|
||||
this._reloadRetry = window.setTimeout(this._onConnReady, 400);
|
||||
return;
|
||||
}
|
||||
if (!this._connectionWasLost && this._continuity.hasCompleteFrame) {
|
||||
this._beginContinuityCandidate('connection-ready', false, 'plan');
|
||||
} else {
|
||||
// A confirmed loss already owns the connection candidate/token.
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
}
|
||||
if (this._loading) return;
|
||||
// a subscribe lost mid-load leaves _loadOk=true without _unsubCfg — the
|
||||
// full load path repairs both (every subscribe in it is guarded)
|
||||
@@ -3000,6 +3181,7 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
private _onConnLost = (): void => {
|
||||
if (this._booting && !this._continuity.hasCompleteFrame) return;
|
||||
this._connectionWasLost = true;
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
this._continuity.connectionLost();
|
||||
@@ -3258,9 +3440,9 @@ class HouseplanCard extends LitElement {
|
||||
private _roomLqi(area: string | null): number | null {
|
||||
if (!area) return null;
|
||||
const vals: number[] = [];
|
||||
for (const d of this._devices) {
|
||||
for (const d of this._renderDevices) {
|
||||
if (d.area !== area || d.virtual) continue;
|
||||
const l = lqiFor(this.hass, d.entities);
|
||||
const l = lqiFor(this._renderPlanHass, d.entities);
|
||||
if (l != null) vals.push(l);
|
||||
}
|
||||
return averageLqi(vals);
|
||||
@@ -3318,6 +3500,11 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Device position in render units of the current space. */
|
||||
private _pos(d: DevItem): { x: number; y: number } {
|
||||
const captured = this._renderDeviceSnapshot?.positions.get(d.id);
|
||||
return captured ? { x: captured.x, y: captured.y } : this._livePos(d);
|
||||
}
|
||||
|
||||
private _livePos(d: DevItem): { x: number; y: number } {
|
||||
const s = this._spaceModel(d.space);
|
||||
const saved = this._layout[d.id];
|
||||
if (saved) {
|
||||
@@ -3388,14 +3575,18 @@ class HouseplanCard extends LitElement {
|
||||
* functional source.
|
||||
*/
|
||||
private _visualSamples(d: DevItem): EntityVisualSample[] {
|
||||
return resolvePresentationSources(this._planHass, d).samples;
|
||||
return resolvePresentationSources(this._renderPlanHass, d).samples;
|
||||
}
|
||||
|
||||
/** One semantic projection consumed by the plan, preview and static card. */
|
||||
private _devicePresentation(
|
||||
d: DevItem, showLqi = true, designPreview = false,
|
||||
): ResolvedDevicePresentation {
|
||||
return resolveDevicePresentation(this._planHass, d, {
|
||||
const captured = !designPreview
|
||||
? this._renderDeviceSnapshot?.presentations.get(presentationSnapshotKey(d.id, showLqi))
|
||||
: null;
|
||||
if (captured) return captured;
|
||||
return resolveDevicePresentation(this._renderPlanHass, d, {
|
||||
liveStates: this._config?.live_states !== false,
|
||||
showTemperature: this._config?.show_temperature !== false,
|
||||
showSignal: showLqi && this._config?.show_signal !== false,
|
||||
@@ -3426,11 +3617,11 @@ class HouseplanCard extends LitElement {
|
||||
// current_temperature gets the same badge a thermometer has. No valid
|
||||
// reading (missing attribute, unavailable) -> no badge at all.
|
||||
if (d.marker?.use_climate_temp === true) {
|
||||
const t = climateTempFor(this.hass, d.entities);
|
||||
const t = climateTempFor(this._renderPlanHass, d.entities);
|
||||
if (t != null) return t;
|
||||
}
|
||||
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') return null;
|
||||
return tempFor(this.hass, d.entities);
|
||||
return tempFor(this._renderPlanHass, d.entities);
|
||||
}
|
||||
|
||||
/** Every HA entity owned by a dialog binding. */
|
||||
@@ -3926,9 +4117,22 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _bootSettled(): void {
|
||||
if (!this._booting || this._bootSettling) return;
|
||||
this._bootSettling = true;
|
||||
this._refitView(); // calculate the final geometry while still covered
|
||||
this._bootSettleRaf = requestAnimationFrame(() => {
|
||||
this._bootSettleRaf = 0;
|
||||
this._finishBootSettled();
|
||||
});
|
||||
}
|
||||
|
||||
private _finishBootSettled(): void {
|
||||
if (!this._booting) return;
|
||||
this._refitView(); // reveal in the final geometry, never mid-jump
|
||||
// `_applyView()` is reactive: wait one paint opportunity, refit once more,
|
||||
// and only then remove the veil. This prevents a default-view reveal.
|
||||
this._refitView();
|
||||
this._booting = false;
|
||||
this._bootSettling = false;
|
||||
// DEV-B703-01: the page has settled once — the next instance with the
|
||||
// same config at the same viewport opens warm (no veil, no wait).
|
||||
const settledH = this._stageEl?.clientHeight ?? 0;
|
||||
@@ -3958,12 +4162,11 @@ class HouseplanCard extends LitElement {
|
||||
private _beginResumeSettle(): void {
|
||||
if (this._booting || this._resumeSettling) return;
|
||||
this._viewportInvalidAt = 0;
|
||||
const token = this._beginContinuityCandidate('warm-resume', false);
|
||||
this._beginContinuityCandidate('warm-resume', false);
|
||||
// A card can be detached for the whole visibility event and reattached as
|
||||
// the same already-loaded instance. In that case `willUpdate()` has no
|
||||
// reason to reload, so the warm tombstone must start its own revalidation
|
||||
// instead of immediately blessing the stale frame as current.
|
||||
if (token !== this._continuity.token) return;
|
||||
if (!this._loading) void this._loadFromServer();
|
||||
else this.requestUpdate();
|
||||
}
|
||||
@@ -7619,8 +7822,9 @@ class HouseplanCard extends LitElement {
|
||||
// entity `liveText` gives the stored text back byte-for-byte, so a
|
||||
// plain label is the plain label it always was (docs/LIVE-TEXT.md).
|
||||
const fs = this._decorTextUnits(sh);
|
||||
const lines = decorTextLines(liveText(
|
||||
sh.text, sh, this.hass, (eid) => this._planEntityAvailable(eid),
|
||||
const frozenText = this._renderDeviceSnapshot?.facts.get(`decor:${this._space}:${sh.id}`);
|
||||
const lines = decorTextLines(typeof frozenText === 'string' ? frozenText : liveText(
|
||||
sh.text, sh, this._renderPlanHass, (eid) => this._renderEntityAvailable(eid),
|
||||
));
|
||||
const ax = sh.x * W, ay = sh.y * H;
|
||||
const ang = Number(sh.angle) || 0;
|
||||
@@ -8848,7 +9052,7 @@ class HouseplanCard extends LitElement {
|
||||
mode,
|
||||
mode === 'lqi' && room.area ? this._roomLqi(room.area) : null,
|
||||
mode === 'light'
|
||||
? resolvedLightState(resolvedLightSources(this._planHass, this._devices, room))
|
||||
? resolvedLightState(resolvedLightSources(this._renderPlanHass, this._renderDevices, room))
|
||||
: 'none',
|
||||
mode === 'temp' ? this._roomTemp(room) : null,
|
||||
disp.tempMin,
|
||||
@@ -11042,7 +11246,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** sun.sun, but only when the feature is armed (north_deg set somewhere). */
|
||||
private _sunNow(): { azimuth: number; elevation: number } | null {
|
||||
return this._effNorth() !== null ? sunStateOf(this.hass) : null;
|
||||
return this._effNorth() !== null ? sunStateOf(this._renderPlanHass) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11056,7 +11260,7 @@ class HouseplanCard extends LitElement {
|
||||
// with rays off, or night. Those never fade, they just are not there.
|
||||
if (this._editing || !this._effSunRays()) { this._sunFadeReset(); return empty; }
|
||||
const north = this._effNorth();
|
||||
const sun = north !== null ? sunStateOf(this.hass) : null;
|
||||
const sun = north !== null ? sunStateOf(this._renderPlanHass) : null;
|
||||
if (!sun || sun.elevation <= 0) { this._sunFadeReset(); return empty; }
|
||||
const alpha = rayPeakAlpha();
|
||||
// The ONE thing that fades (owner 2026-08-03): the 3° threshold. Above it
|
||||
@@ -11757,8 +11961,8 @@ class HouseplanCard extends LitElement {
|
||||
const physical = this._physicalBodiesR(space);
|
||||
const { occluders, floor, fingerprint } = this._lightBarriers(space, polys, physical);
|
||||
const resolvedSources = resolvedLightSources(
|
||||
this._planHass,
|
||||
this._devices.filter((d) => d.space === space.id),
|
||||
this._renderPlanHass,
|
||||
this._renderDevices.filter((d) => d.space === space.id),
|
||||
);
|
||||
const sourcesByDevice = new Map<string, typeof resolvedSources>();
|
||||
for (const source of resolvedSources) {
|
||||
@@ -11780,7 +11984,7 @@ class HouseplanCard extends LitElement {
|
||||
r: number;
|
||||
}[] = [];
|
||||
const seenSourceKeys = new Set<string>();
|
||||
for (const d of this._devices) {
|
||||
for (const d of this._renderDevices) {
|
||||
if (d.space !== space.id) continue;
|
||||
const source = selectSpatialGlowSource(sourcesByDevice.get(d.id) || []);
|
||||
if (!source) continue;
|
||||
@@ -11789,7 +11993,7 @@ class HouseplanCard extends LitElement {
|
||||
const key = `${space.id}|${d.id}`;
|
||||
seenSourceKeys.add(key);
|
||||
const visibleGlow = resolveGlowAppearance(
|
||||
this._planHass.states[source.eid], d.marker?.glow_color, colors.glow_light.c,
|
||||
this._renderPlanHass.states[source.eid], d.marker?.glow_color, colors.glow_light.c,
|
||||
);
|
||||
// per-source radius (owner's decision v1.36.2): marker override, else global
|
||||
const ownCm = Number(d.marker?.glow_radius_cm);
|
||||
@@ -12317,7 +12521,8 @@ class HouseplanCard extends LitElement {
|
||||
// (ghosted); everywhere else the flag removes them from sight — but not
|
||||
// from the build, so room LQI still counts them (docs/FILTERING.md)
|
||||
const showGhosts = this._mode === 'devices' && this._showAll;
|
||||
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
|
||||
const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
|
||||
const deviceSnapshot = this._renderDeviceSnapshot;
|
||||
const disp = this._spaceDisplayForRender();
|
||||
const roomFills = this._resolvedRoomFills(space, disp);
|
||||
const glowBase = this._resolvedGlowBase(space, disp, roomFills);
|
||||
@@ -12347,6 +12552,7 @@ class HouseplanCard extends LitElement {
|
||||
data-continuity-state=${this._continuity.state}
|
||||
data-continuity-token=${this._continuity.token}
|
||||
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
|
||||
data-device-snapshot-sequence=${deviceSnapshot?.sourceSequence ?? nothing}
|
||||
data-recovery-reason=${recoveryReason || nothing}
|
||||
data-ha-registry-access=${diagnostics.registry.access}
|
||||
data-ha-disabled-bindings=${diagnostics.bindings.ha_disabled}
|
||||
@@ -12480,7 +12686,7 @@ class HouseplanCard extends LitElement {
|
||||
${space.bg && backdropHref
|
||||
? svg`<image href="${backdropHref}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}"
|
||||
opacity="${this._mode === 'decor' && this._decorTool !== 'backdrop' ? 0.5 : 1}"
|
||||
@load=${() => this._onBackdropLoaded(space.bg!.href)}
|
||||
@load=${() => this._onBackdropLoaded(space.bg!.href, backdropHref)}
|
||||
transform=${space.bg.angle
|
||||
? `rotate(${space.bg.angle} ${space.bg.x + space.bg.w / 2} ${space.bg.y + space.bg.h / 2})`
|
||||
: nothing}
|
||||
@@ -12787,12 +12993,14 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
// ---------------- live robot vacuums (docs/VACUUM.md) ----------------
|
||||
|
||||
private _vacCandidateStatus(entityId: string, candidate: VacSourceCandidate | null): VacSourceStatus {
|
||||
private _vacCandidateStatus(
|
||||
entityId: string, candidate: VacSourceCandidate | null, planHass = this._planHass,
|
||||
): VacSourceStatus {
|
||||
const binding = this._bindingStatus('entity:' + entityId);
|
||||
if (binding.kind === 'ha_disabled') return 'disabled';
|
||||
if (binding.kind === 'orphaned') return 'missing';
|
||||
if (binding.kind === 'unverified') return 'unverified';
|
||||
const state = this.hass?.states?.[entityId];
|
||||
const state = planHass?.states?.[entityId];
|
||||
if (state?.state === 'unavailable') return 'unavailable';
|
||||
return candidate?.hasPosition ? 'ok' : 'unsupported';
|
||||
}
|
||||
@@ -12811,16 +13019,18 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** One sticky, order-independent source resolver for render/dialog/fit. */
|
||||
private _vacSourceResolution(d: DevItem, includeAllCameras = false): VacSourceResolution {
|
||||
private _vacSourceResolution(
|
||||
d: DevItem, includeAllCameras = false, planHass = this._planHass,
|
||||
): VacSourceResolution {
|
||||
const vacuum = d.marker?.vacuum;
|
||||
const pinned = typeof vacuum?.source === 'string' && !!vacuum.source;
|
||||
const sameDevice = new Set(d.entities || []);
|
||||
const ids = new Set<string>(sameDevice);
|
||||
if (pinned) ids.add(vacuum!.source!);
|
||||
const registry = this._haRegistry.entities || {};
|
||||
const registry = planHass?.entities || {};
|
||||
const candidates: VacSourceCandidate[] = [];
|
||||
for (const entityId of ids) {
|
||||
const state = this.hass?.states?.[entityId];
|
||||
const state = planHass?.states?.[entityId];
|
||||
const candidate = parseVacSourceCandidate(entityId, state, registry[entityId]);
|
||||
if (candidate) candidates.push(candidate);
|
||||
else if (pinned && entityId === vacuum!.source) {
|
||||
@@ -12841,15 +13051,15 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
const statuses: Record<string, VacSourceStatus> = {};
|
||||
for (const candidate of candidates) {
|
||||
statuses[candidate.entityId] = this._vacCandidateStatus(candidate.entityId, candidate);
|
||||
statuses[candidate.entityId] = this._vacCandidateStatus(candidate.entityId, candidate, planHass);
|
||||
}
|
||||
return resolveVacSource(vacuum?.source, sameDevice, candidates, statuses);
|
||||
}
|
||||
|
||||
/** The sticky/automatic live-position source entity, or null. */
|
||||
private _vacSource(d: DevItem): string | null {
|
||||
private _vacSource(d: DevItem, planHass = this._planHass): string | null {
|
||||
if (d.marker?.vacuum?.live === false) return null;
|
||||
const resolution = this._vacSourceResolution(d);
|
||||
const resolution = this._vacSourceResolution(d, false, planHass);
|
||||
// A disabled/unavailable/unverified source must never leak stale HA
|
||||
// attributes into plan overlays. `unsupported` remains usable because it
|
||||
// may still provide rooms/path for calibration and trail rendering.
|
||||
@@ -13199,11 +13409,11 @@ class HouseplanCard extends LitElement {
|
||||
* human-readable one on the vacuum entity (selected_map, verified against a
|
||||
* live X50 Master) — without this both floors would share one matrix.
|
||||
*/
|
||||
private _vacMapId(d: DevItem, tele: { mapId: string }): string {
|
||||
private _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this._planHass): string {
|
||||
// HP-1541-01: nullish, not truthy — selected_map: 0 is a real map id and
|
||||
// must equal what trails.py resolve_map_id stores server-side.
|
||||
const ve = this._vacEntity(d);
|
||||
const sel = ve ? this.hass?.states[ve]?.attributes?.selected_map : null;
|
||||
const sel = ve ? planHass?.states?.[ve]?.attributes?.selected_map : null;
|
||||
return vacMapIdWithFallback(tele.mapId, sel);
|
||||
}
|
||||
|
||||
@@ -13399,7 +13609,7 @@ class HouseplanCard extends LitElement {
|
||||
private _renderVacFit(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
|
||||
const f = this._vacFit;
|
||||
if (!f) return nothing;
|
||||
const tele = readVacTelemetry(this.hass?.states[f.source]?.attributes);
|
||||
const tele = readVacTelemetry(this._renderPlanHass?.states?.[f.source]?.attributes);
|
||||
if (!tele) return nothing;
|
||||
const m = fitMatrix(f.p);
|
||||
const rects: TemplateResult[] = [];
|
||||
@@ -13485,20 +13695,21 @@ class HouseplanCard extends LitElement {
|
||||
for (const d of devs) {
|
||||
if (d.hidden || !this._isVacDev(d)) continue;
|
||||
if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') continue;
|
||||
const src = this._vacSource(d);
|
||||
const fact = this._renderDeviceSnapshot?.facts.get(`vacuum:${d.id}`) as any;
|
||||
const src = fact?.source ?? this._vacSource(d, this._renderPlanHass);
|
||||
if (!src) continue;
|
||||
const tele = readVacTelemetry(this.hass?.states[src]?.attributes);
|
||||
const tele = fact?.telemetry ?? readVacTelemetry(this._renderPlanHass?.states[src]?.attributes);
|
||||
if (!tele) continue;
|
||||
const matrix = d.marker?.vacuum?.calibration?.[this._vacMapId(d, tele)] as Affine | undefined;
|
||||
const mapNow = String(fact?.mapId ?? this._vacMapId(d, tele, this._renderPlanHass));
|
||||
const matrix = d.marker?.vacuum?.calibration?.[mapNow] as Affine | undefined;
|
||||
if (!matrix || matrix.length !== 6) continue;
|
||||
const rt = this._vacRt.get(d.id);
|
||||
const rt = fact?.runtime ?? this._vacRt.get(d.id);
|
||||
const moving = rt?.moving ?? false;
|
||||
const tmode = vacTrailMode(d.marker?.vacuum);
|
||||
// owner 2026-07-31: hide when the cleanup is over (default), unless the
|
||||
// mode says always; the previous run only ever shows in 'always'
|
||||
const showCur = tmode === 'always' || (tmode === 'cleaning' && moving);
|
||||
const srv = this._vacSrvTrails[d.id];
|
||||
const mapNow = this._vacMapId(d, tele);
|
||||
const srv = fact?.server ?? this._vacSrvTrails[d.id];
|
||||
const srvCur = srv?.current?.map_id === mapNow && Array.isArray(srv.current.points) ? srv.current : null;
|
||||
const srvPrev = srv?.previous?.map_id === mapNow && Array.isArray(srv.previous.points) ? srv.previous : null;
|
||||
// the PREVIOUS run stays visible even at rest: users compare where the
|
||||
@@ -13622,14 +13833,14 @@ class HouseplanCard extends LitElement {
|
||||
const cmPerUnit = this._cellCm / this._gridPitch;
|
||||
return formatArea(
|
||||
(clean.area * cmPerUnit * cmPerUnit) / 1e4,
|
||||
this.hass?.config?.unit_system?.length === 'mi',
|
||||
this._renderPlanHass?.config?.unit_system?.length === 'mi',
|
||||
);
|
||||
}
|
||||
|
||||
/** Room temperature honouring the tier-3 source override. */
|
||||
private _roomTemp(r: RoomCfg): number | null {
|
||||
const src = r.settings?.temp_source;
|
||||
if (src) return sourceValue(this._planHass, src, 'temp', this._markers);
|
||||
if (src) return sourceValue(this._renderPlanHass, src, 'temp', this._markers);
|
||||
// every sensor of the area, placed on the plan or not (field report)
|
||||
return r.area ? this._climate().get(r.area)?.temp ?? null : null;
|
||||
}
|
||||
@@ -13637,7 +13848,7 @@ class HouseplanCard extends LitElement {
|
||||
/** Room humidity honouring the tier-3 source override. */
|
||||
private _roomHum(r: RoomCfg): number | null {
|
||||
const src = r.settings?.hum_source;
|
||||
if (src) return sourceValue(this._planHass, src, 'hum', this._markers);
|
||||
if (src) return sourceValue(this._renderPlanHass, src, 'hum', this._markers);
|
||||
return r.area ? this._climate().get(r.area)?.hum ?? null : null;
|
||||
}
|
||||
|
||||
@@ -13654,7 +13865,7 @@ class HouseplanCard extends LitElement {
|
||||
// markers are part of the key: ticking "use the device's temperature
|
||||
// sensor" replaces the markers array, so the average recomputes at once
|
||||
const mk = this._serverCfg?.markers;
|
||||
const planHass = this._planHass;
|
||||
const planHass = this._renderPlanHass;
|
||||
const c = this._climateCache;
|
||||
if (c && c.h === planHass && c.r === this._iconRules && c.mk === mk) return c.m;
|
||||
const m = areaClimateMap(planHass, this._iconRules, mk);
|
||||
@@ -13947,7 +14158,7 @@ class HouseplanCard extends LitElement {
|
||||
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
|
||||
}
|
||||
if (disp.labelLight) {
|
||||
const ls = resolvedLightStats(resolvedLightSources(this._planHass, this._devices, r));
|
||||
const ls = resolvedLightStats(resolvedLightSources(this._renderPlanHass, this._renderDevices, r));
|
||||
if (ls) {
|
||||
const txt = ls.on === 0
|
||||
? this._t('roomcard.light_off')
|
||||
@@ -14172,8 +14383,8 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Live state of an opening's contact, 0..1 drawn amount. */
|
||||
private _openingAmt(o: OpeningCfg): number {
|
||||
const st = o.contact && this._planEntityAvailable(o.contact)
|
||||
? this.hass.states[o.contact]?.state
|
||||
const st = o.contact && this._renderEntityAvailable(o.contact)
|
||||
? this._renderPlanHass.states[o.contact]?.state
|
||||
: null;
|
||||
return openingAmount(o.type, st, !!o.invert);
|
||||
}
|
||||
@@ -14185,6 +14396,13 @@ class HouseplanCard extends LitElement {
|
||||
return this._bindingStatus('entity:' + eid).kind === 'active';
|
||||
}
|
||||
|
||||
/** Availability from the same immutable projection as the painted frame. */
|
||||
private _renderEntityAvailable(eid: string | null | undefined): boolean {
|
||||
if (!eid) return false;
|
||||
if (isRemovedPlanEntity(this._renderPlanHass, eid, removedPlanBindings(this._markers))) return false;
|
||||
return !!this._renderPlanHass.entities?.[eid] && !!this._renderPlanHass.states?.[eid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Doors, windows and gates, drawn in plan (SVG) coordinates so they scale and
|
||||
* pan with the plan. Doors/windows follow the easy-floorplan (MIT) symbol
|
||||
@@ -14200,7 +14418,7 @@ class HouseplanCard extends LitElement {
|
||||
return svg`${items.map((o) => {
|
||||
const half = o.rlen / 2;
|
||||
const amt = this._openingAmt(o);
|
||||
const active = amt > 0 && !!o.contact && this._planEntityAvailable(o.contact);
|
||||
const active = amt > 0 && !!o.contact && this._renderEntityAvailable(o.contact);
|
||||
const tone = active ? 'var(--hp-open)' : base;
|
||||
// A normal door/window uses the selected room-side face. A gate's
|
||||
// architectural symbol sits and opens on the opposite (exterior) face;
|
||||
@@ -14315,13 +14533,13 @@ class HouseplanCard extends LitElement {
|
||||
/** Padlock badges for door-like openings with a lock entity. */
|
||||
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||
const items = this._openingsR.filter(
|
||||
(o) => o.type !== 'window' && o.lock && this._planEntityAvailable(o.lock),
|
||||
(o) => o.type !== 'window' && o.lock && this._renderEntityAvailable(o.lock),
|
||||
);
|
||||
if (!items.length) return html``;
|
||||
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
||||
const openingWallIndex = this._openingWallIndexFor(this._spaceModel(), openCuts).value;
|
||||
return html`${items.map((o) => {
|
||||
const st = this.hass.states[o.lock!]?.state;
|
||||
const st = this._renderPlanHass.states[o.lock!]?.state;
|
||||
const locked = st === 'locked';
|
||||
const known = locked || ['unlocked', 'open', 'opening', 'unlocking', 'locking'].includes(String(st));
|
||||
// Perpendicular offset from the opening center, away from the swing side.
|
||||
|
||||
+2
-2
@@ -342,8 +342,8 @@
|
||||
"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.restore_plan": "Restoring floor plan…",
|
||||
"continuity.restore_connection": "Restoring device connection…",
|
||||
"continuity.retry": "Try again",
|
||||
"editor.space": "Space",
|
||||
"editor.show_button": "Show button",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { ResolvedDevicePresentation } from './device-presentation';
|
||||
import type { DevItem } from './types';
|
||||
|
||||
/** Immutable render-only projection of one HA tick. */
|
||||
export interface RenderDeviceSnapshot {
|
||||
readonly sourceSequence: number;
|
||||
readonly capturedAt: number;
|
||||
/** Minimal function-free HA-compatible projection used by pure resolvers. */
|
||||
readonly hass: Readonly<{
|
||||
states: Readonly<Record<string, any>>;
|
||||
entities: Readonly<Record<string, any>>;
|
||||
devices: Readonly<Record<string, any>>;
|
||||
config?: any;
|
||||
locale?: any;
|
||||
themes?: any;
|
||||
}>;
|
||||
readonly devices: readonly DevItem[];
|
||||
readonly positions: ReadonlyMap<string, Readonly<{ x: number; y: number }>>;
|
||||
/** Two variants per device: `${id}:1` with LQI and `${id}:0` without it. */
|
||||
readonly presentations: ReadonlyMap<string, ResolvedDevicePresentation>;
|
||||
/** Extra resolved render facts (for example a vacuum puck/trail frame). */
|
||||
readonly facts: ReadonlyMap<string, unknown>;
|
||||
}
|
||||
|
||||
function cloneFact<T>(value: T, stack = new WeakMap<object, unknown>()): T {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
const object = value as unknown as object;
|
||||
const found = stack.get(object);
|
||||
if (found) return found as T;
|
||||
if (Array.isArray(value)) {
|
||||
const out: unknown[] = [];
|
||||
stack.set(object, out);
|
||||
for (const item of value) out.push(cloneFact(item, stack));
|
||||
return Object.freeze(out) as T;
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
stack.set(object, out);
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
// Registry/state facts are data. Functions belong to live HA and never
|
||||
// cross the render-snapshot boundary.
|
||||
if (typeof item !== 'function') out[key] = cloneFact(item, stack);
|
||||
}
|
||||
return Object.freeze(out) as T;
|
||||
}
|
||||
|
||||
/** Runtime-immutable ReadonlyMap facade: Object.freeze(new Map()) still allows set(). */
|
||||
function readonlyMap<K, V>(entries: Iterable<readonly [K, V]>): ReadonlyMap<K, V> {
|
||||
const source = new Map(entries);
|
||||
let view: ReadonlyMap<K, V>;
|
||||
view = Object.freeze({
|
||||
get size() { return source.size; },
|
||||
get: (key: K) => source.get(key),
|
||||
has: (key: K) => source.has(key),
|
||||
entries: () => source.entries(),
|
||||
keys: () => source.keys(),
|
||||
values: () => source.values(),
|
||||
forEach: (callback: (value: V, key: K, map: ReadonlyMap<K, V>) => void, thisArg?: unknown) =>
|
||||
source.forEach((value, key) => callback.call(thisArg, value, key, view)),
|
||||
[Symbol.iterator]: () => source[Symbol.iterator](),
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
export function createRenderDeviceSnapshot(options: {
|
||||
sourceSequence: number;
|
||||
hass: any;
|
||||
devices: readonly DevItem[];
|
||||
presentations: ReadonlyMap<string, ResolvedDevicePresentation>;
|
||||
positions?: ReadonlyMap<string, { x: number; y: number }>;
|
||||
facts?: ReadonlyMap<string, unknown>;
|
||||
entityIds?: Iterable<string>;
|
||||
deviceIds?: Iterable<string>;
|
||||
areaIds?: Iterable<string>;
|
||||
capturedAt?: number;
|
||||
}): RenderDeviceSnapshot {
|
||||
// Copy only the registry/state rows that can contribute to this plan. This
|
||||
// is a render-data projection, not a frozen clone of Home Assistant runtime.
|
||||
const entityIds = new Set(options.entityIds || []);
|
||||
const deviceIds = new Set(options.deviceIds || []);
|
||||
const areaIds = new Set(options.areaIds || []);
|
||||
for (const device of options.devices) {
|
||||
for (const entityId of device.entities || []) entityIds.add(entityId);
|
||||
if (device.primary) entityIds.add(device.primary);
|
||||
for (const entityId of device.controls || []) entityIds.add(entityId);
|
||||
if (device.marker?.vacuum?.source) entityIds.add(device.marker.vacuum.source);
|
||||
if (device.bindingKind === 'device' && device.bindingRef) deviceIds.add(device.bindingRef);
|
||||
if (device.bindingKind === 'entity' && device.bindingRef) entityIds.add(device.bindingRef);
|
||||
}
|
||||
const projectedEntities: Record<string, any> = {};
|
||||
const projectedDevices: Record<string, any> = {};
|
||||
for (const [entityId, registry] of Object.entries<any>(options.hass?.entities || {})) {
|
||||
const device = registry?.device_id ? options.hass?.devices?.[registry.device_id] : null;
|
||||
if (entityIds.has(entityId) || (registry?.device_id && deviceIds.has(registry.device_id))
|
||||
|| areaIds.has(registry?.area_id) || areaIds.has(device?.area_id)) {
|
||||
entityIds.add(entityId);
|
||||
projectedEntities[entityId] = registry;
|
||||
if (registry?.device_id) deviceIds.add(registry.device_id);
|
||||
}
|
||||
}
|
||||
for (const deviceId of deviceIds) {
|
||||
const device = options.hass?.devices?.[deviceId];
|
||||
if (device) projectedDevices[deviceId] = device;
|
||||
}
|
||||
const projectedStates: Record<string, any> = {};
|
||||
for (const entityId of entityIds) {
|
||||
const state = options.hass?.states?.[entityId];
|
||||
if (state) projectedStates[entityId] = state;
|
||||
}
|
||||
const hass = Object.freeze({
|
||||
states: cloneFact(projectedStates),
|
||||
entities: cloneFact(projectedEntities),
|
||||
devices: cloneFact(projectedDevices),
|
||||
config: cloneFact(options.hass?.config),
|
||||
locale: cloneFact(options.hass?.locale),
|
||||
themes: cloneFact(options.hass?.themes),
|
||||
});
|
||||
return Object.freeze({
|
||||
sourceSequence: options.sourceSequence,
|
||||
capturedAt: options.capturedAt ?? Date.now(),
|
||||
hass,
|
||||
devices: cloneFact([...options.devices]),
|
||||
positions: readonlyMap([...(options.positions || [])].map(([id, point]) =>
|
||||
[id, Object.freeze({ x: point.x, y: point.y })] as const)),
|
||||
presentations: readonlyMap([...options.presentations].map(([key, presentation]) =>
|
||||
[key, cloneFact(presentation)] as const)),
|
||||
facts: readonlyMap([...(options.facts || [])].map(([key, fact]) =>
|
||||
[key, cloneFact(fact)] as const)),
|
||||
});
|
||||
}
|
||||
|
||||
export const presentationSnapshotKey = (deviceId: string, showLqi: boolean): string =>
|
||||
`${deviceId}:${showLqi ? 1 : 0}`;
|
||||
+42
-6
@@ -1,4 +1,5 @@
|
||||
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
|
||||
import { PAINT_BARRIER_MAX_MS } from './visual-continuity';
|
||||
|
||||
/** A request older than this is presumed lost, and the url may be asked again. */
|
||||
export const SIGN_INFLIGHT_MS = 15000;
|
||||
@@ -33,7 +34,11 @@ const sharedSignerRuntimes = new WeakMap<object, SharedSignerRuntime>();
|
||||
const SHARED_SIGNED_MAX = 512;
|
||||
|
||||
const signerAuthority = (hass: any, fallback: object): object => {
|
||||
const authority = hass?.connection || hass;
|
||||
// `hass` itself is commonly replaced on every HA state tick. Keying the
|
||||
// shared signer by it discarded the cache continuously in reduced/demo
|
||||
// environments without a Connection object. The transport function is the
|
||||
// next stable authority; a per-signer fallback is the final option.
|
||||
const authority = hass?.connection || hass?.callWS || fallback;
|
||||
return authority && (typeof authority === 'object' || typeof authority === 'function')
|
||||
? authority : fallback;
|
||||
};
|
||||
@@ -301,7 +306,11 @@ export class ContentSigner {
|
||||
const url = entry.url;
|
||||
entry.preload = new Promise<boolean>((resolve) => {
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
const finish = (ready: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const current = shared.signed[path];
|
||||
if (current === entry) delete current.preload;
|
||||
if (ready && current === entry && current.url === url) {
|
||||
@@ -318,6 +327,10 @@ export class ContentSigner {
|
||||
decoded.then(() => finish(true)).catch(() => finish(false));
|
||||
};
|
||||
image.onerror = () => finish(false);
|
||||
// A browser may leave an image request pending forever (sleep, dead
|
||||
// service worker, stalled authenticated endpoint). Release the shared
|
||||
// preload slot so Retry can start a genuinely new request.
|
||||
const timer = setTimeout(() => finish(false), PAINT_BARRIER_MAX_MS);
|
||||
image.src = url;
|
||||
});
|
||||
return entry.preload;
|
||||
@@ -344,8 +357,19 @@ export class ContentSigner {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const bounded = (promise: Promise<boolean>): Promise<boolean> => new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (ready: boolean): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve(ready);
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), PAINT_BARRIER_MAX_MS);
|
||||
promise.then(finish).catch(() => finish(false));
|
||||
});
|
||||
const immediate = attempt();
|
||||
if (immediate) return immediate;
|
||||
if (immediate) return bounded(immediate);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let done = false;
|
||||
let active: Promise<boolean> | null = null;
|
||||
@@ -362,7 +386,7 @@ export class ContentSigner {
|
||||
if (!active) return;
|
||||
active.then(finish).catch(() => finish(false));
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), SIGN_INFLIGHT_MS);
|
||||
const timer = setTimeout(() => finish(false), PAINT_BARRIER_MAX_MS);
|
||||
shared.settlers.add(check);
|
||||
check();
|
||||
});
|
||||
@@ -378,8 +402,18 @@ export class ContentSigner {
|
||||
private trimShared(shared: SharedSignerRuntime): void {
|
||||
const entries = Object.entries(shared.signed);
|
||||
if (entries.length <= SHARED_SIGNED_MAX) return;
|
||||
const protectedPaths = new Set<string>(shared.inFlight.keys());
|
||||
for (const refs of shared.references.values()) for (const path of refs) protectedPaths.add(path);
|
||||
for (const [path, entry] of entries) {
|
||||
if (entry.pending || entry.preload) protectedPaths.add(path);
|
||||
}
|
||||
entries.sort((left, right) => right[1].at - left[1].at);
|
||||
shared.signed = Object.fromEntries(entries.slice(0, SHARED_SIGNED_MAX));
|
||||
const kept = entries.filter(([path]) => protectedPaths.has(path));
|
||||
for (const entry of entries) {
|
||||
if (kept.length >= SHARED_SIGNED_MAX) break;
|
||||
if (!protectedPaths.has(entry[0])) kept.push(entry);
|
||||
}
|
||||
shared.signed = Object.fromEntries(kept);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,11 +438,13 @@ export class ContentSigner {
|
||||
}
|
||||
|
||||
/** Mark a protected image as decoded/paintable; shared warm mounts inherit it. */
|
||||
markLoaded(hass: any, url: string | null | undefined): void {
|
||||
markLoaded(hass: any, url: string | null | undefined, paintedUrl?: string): void {
|
||||
const path = contentUrl(url);
|
||||
if (!path.startsWith('/api/houseplan/content/')) return;
|
||||
const entry = this.bind(hass).signed[path];
|
||||
if (entry && !entry.loaded) {
|
||||
// A late load event from an expired/re-signed URL must not bless the newer
|
||||
// entry that happens to share the same raw content path.
|
||||
if (entry && (!paintedUrl || entry.url === paintedUrl) && !entry.loaded) {
|
||||
entry.loaded = true;
|
||||
for (const listener of [...this.bind(hass).listeners]) listener();
|
||||
}
|
||||
|
||||
+142
-21
@@ -15,9 +15,12 @@ import { normalizeDeviceDisplay, referencedContentUrls } from './logic';
|
||||
import { acquireHaRegistries, activeRegistryHass, haRegistrySnapshot } from './ha-binding-status';
|
||||
import { edgeActivity } from './device-visual';
|
||||
import {
|
||||
presentationSourceSignature, resolvePresentationSources,
|
||||
presentationSourceSignature, resolveDevicePresentation, resolvePresentationSources,
|
||||
type PresentationActivityRuntime,
|
||||
} from './device-presentation';
|
||||
import {
|
||||
createRenderDeviceSnapshot, presentationSnapshotKey, type RenderDeviceSnapshot,
|
||||
} from './render-device-snapshot';
|
||||
import type { DevItem } from './types';
|
||||
import {
|
||||
VisualContinuityController,
|
||||
@@ -76,6 +79,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
private _haRegistryRevision = -1;
|
||||
private _devices: DevItem[] = [];
|
||||
private _continuity = this._newContinuityController();
|
||||
private _continuityHistory: import('./visual-continuity').ContinuityTraceEvent[] = [];
|
||||
private _continuityUnsub?: () => void;
|
||||
private _continuityEpoch = 0;
|
||||
private _continuityDataReady = true;
|
||||
@@ -84,11 +88,20 @@ class HouseplanSpaceCard extends LitElement {
|
||||
private _renderSnapshotAt = Date.now();
|
||||
private _hassSequence = 0;
|
||||
private _connHooked: any = null;
|
||||
private _connectionWasLost = false;
|
||||
private _visibleDeviceSnapshot: RenderDeviceSnapshot | null = null;
|
||||
private _candidateDeviceSnapshot: RenderDeviceSnapshot | null = null;
|
||||
private _stagedDeviceSnapshotToken = -1;
|
||||
private _capturedSnapshotSequence = -1;
|
||||
private _capturedSnapshotDevices: DevItem[] | null = null;
|
||||
private _capturedSnapshotActivity = '';
|
||||
private _activityRuntime = new Map<string, StaticActivityRuntime>();
|
||||
private _onHaRegistryUpdate = () => {
|
||||
const revision = haRegistrySnapshot(this.hass).revision;
|
||||
if (revision === this._haRegistryRevision) return;
|
||||
this._haRegistryRevision = revision;
|
||||
this._refreshDevices();
|
||||
this._capturedSnapshotSequence = -1;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
@@ -117,22 +130,30 @@ class HouseplanSpaceCard extends LitElement {
|
||||
}
|
||||
|
||||
private _pageVisibility = (signal: PageVisibilitySignal): void => {
|
||||
const token = this._continuity.visibility(signal);
|
||||
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);
|
||||
void this._load(true);
|
||||
};
|
||||
|
||||
private _onConnLost = (): void => {
|
||||
this._connectionWasLost = true;
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
this._continuity.connectionLost();
|
||||
};
|
||||
|
||||
private _onConnReady = (): void => {
|
||||
this._beginContinuityCandidate('connection-ready', false, 'connection');
|
||||
// `ready` also fires for a healthy initial connection. Only a preceding
|
||||
// confirmed loss is allowed to put the card into offline-stale semantics.
|
||||
if (!this._connectionWasLost && this._continuity.hasCompleteFrame) {
|
||||
this._beginContinuityCandidate('connection-ready', false, 'plan');
|
||||
} else {
|
||||
this._continuityDataReady = false;
|
||||
this._continuityPaintToken = -1;
|
||||
}
|
||||
void this._load(true);
|
||||
};
|
||||
|
||||
@@ -216,6 +237,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._connHooked = null;
|
||||
for (const runtime of this._activityRuntime.values()) window.clearTimeout(runtime.timer);
|
||||
this._activityRuntime.clear();
|
||||
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
|
||||
this._continuity.dispose();
|
||||
this._continuityDisposed = true;
|
||||
super.disconnectedCallback();
|
||||
@@ -232,7 +254,10 @@ class HouseplanSpaceCard extends LitElement {
|
||||
if (this.hass && !this._loading && (!this._snap || changed.has('hass'))) {
|
||||
if (!this._snap || !this._loadedOnce) this._load();
|
||||
}
|
||||
this._refreshDevices();
|
||||
if (changed.has('hass') || changed.has('_snap') || changed.has('_config') || !this._devices.length) {
|
||||
this._refreshDevices();
|
||||
}
|
||||
this._captureRenderDeviceSnapshot();
|
||||
if (this._continuity.hasCompleteFrame && this._continuity.state === 'steady') {
|
||||
this._continuity.refreshCompleteFrame(this._frameFingerprint());
|
||||
}
|
||||
@@ -313,6 +338,72 @@ class HouseplanSpaceCard extends LitElement {
|
||||
this._devices = devices;
|
||||
}
|
||||
|
||||
private _captureRenderDeviceSnapshot(): void {
|
||||
if (!this.hass) return;
|
||||
const activity = [...this._activityRuntime.entries()]
|
||||
.map(([id, runtime]) => `${id}:${runtime.gen}:${runtime.flashTs}`).join('|');
|
||||
if (this._capturedSnapshotSequence === this._hassSequence
|
||||
&& this._capturedSnapshotDevices === this._devices
|
||||
&& this._capturedSnapshotActivity === activity) return;
|
||||
const planHass = activeRegistryHass(this.hass, haRegistrySnapshot(this.hass));
|
||||
const presentations = new Map<string, ReturnType<typeof resolveDevicePresentation>>();
|
||||
const entityIds = new Set<string>(['sun.sun']);
|
||||
const deviceIds = new Set<string>();
|
||||
const areaIds = new Set<string>();
|
||||
const addSource = (source: string | null | undefined): void => {
|
||||
if (!source) return;
|
||||
const separator = source.indexOf(':');
|
||||
if (separator < 0) { entityIds.add(source); return; }
|
||||
const kind = source.slice(0, separator), reference = source.slice(separator + 1);
|
||||
if (kind === 'device') deviceIds.add(reference);
|
||||
else if (kind === 'entity') entityIds.add(reference);
|
||||
};
|
||||
const selectedSpace = spaceModels(this._snap?.config || null)
|
||||
.find((space) => space.id === this._config?.space);
|
||||
for (const room of selectedSpace?.rooms || []) {
|
||||
if (room.area) areaIds.add(room.area);
|
||||
addSource(room.settings?.temp_source);
|
||||
addSource(room.settings?.hum_source);
|
||||
}
|
||||
const rawSpace = this._snap?.config?.spaces?.find((space: any) => space.id === this._config?.space);
|
||||
for (const opening of rawSpace?.openings || []) {
|
||||
if (opening.contact) entityIds.add(opening.contact);
|
||||
if (opening.lock) entityIds.add(opening.lock);
|
||||
}
|
||||
for (const device of this._devices) {
|
||||
for (const showLqi of [false, true]) {
|
||||
presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation(
|
||||
planHass, device, {
|
||||
liveStates: this._config?.live_states !== false,
|
||||
showTemperature: this._config?.show_temperature !== false,
|
||||
showSignal: showLqi,
|
||||
activityRuntime: this._activityRuntime.get(device.id),
|
||||
sourceDetails: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
const snapshot = createRenderDeviceSnapshot({
|
||||
sourceSequence: this._hassSequence, hass: planHass,
|
||||
devices: this._devices, presentations, entityIds, deviceIds, areaIds,
|
||||
});
|
||||
this._capturedSnapshotSequence = this._hassSequence;
|
||||
this._capturedSnapshotDevices = this._devices;
|
||||
this._capturedSnapshotActivity = activity;
|
||||
if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') {
|
||||
this._visibleDeviceSnapshot = snapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
} else {
|
||||
this._candidateDeviceSnapshot = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
private get _renderDeviceSnapshot(): RenderDeviceSnapshot | null {
|
||||
return this._stagedDeviceSnapshotToken === this._continuity.token
|
||||
? this._candidateDeviceSnapshot || this._visibleDeviceSnapshot
|
||||
: this._visibleDeviceSnapshot || this._candidateDeviceSnapshot;
|
||||
}
|
||||
|
||||
private _beginContinuityCandidate(
|
||||
reason: string,
|
||||
dataReady: boolean,
|
||||
@@ -320,6 +411,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
): number {
|
||||
this._continuityDataReady = dataReady;
|
||||
this._continuityPaintToken = -1;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
return this._continuity.beginCandidate(reason, recoveryReason);
|
||||
}
|
||||
|
||||
@@ -347,7 +439,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
snap?.layoutFingerprint || contentFingerprint(snap?.layout),
|
||||
this._config?.space || '',
|
||||
this._stageWidth,
|
||||
this._hassSequence,
|
||||
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
|
||||
]);
|
||||
}
|
||||
@@ -360,18 +451,24 @@ class HouseplanSpaceCard extends LitElement {
|
||||
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;
|
||||
}
|
||||
if (!this._assetsReady()) this._beginContinuityCandidate('asset-wait', true, 'asset');
|
||||
else {
|
||||
this._renderSnapshotAt = Date.now();
|
||||
this._continuity.markCompleteFrame(this._frameFingerprint());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this._continuityDataReady || !this._assetsReady()) return;
|
||||
if (!this._continuityDataReady) return;
|
||||
if (!['holding', 'offline-stale', 'overlay-pending', 'overlay-visible', 'candidate-ready']
|
||||
.includes(this._continuity.state)) return;
|
||||
const token = this._continuity.token;
|
||||
if (this._candidateDeviceSnapshot
|
||||
&& this._candidateDeviceSnapshot !== this._visibleDeviceSnapshot
|
||||
&& this._stagedDeviceSnapshotToken !== token) {
|
||||
this._stagedDeviceSnapshotToken = token;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (this._continuityPaintToken === token) return;
|
||||
this._continuityPaintToken = token;
|
||||
if (!this._continuity.candidateReady(token)) return;
|
||||
@@ -382,15 +479,23 @@ class HouseplanSpaceCard extends LitElement {
|
||||
frameFingerprint: () => this._frameFingerprint(),
|
||||
}).then((committed) => {
|
||||
if (!committed || token !== this._continuity.token) {
|
||||
if (token === this._continuity.token) this._continuityPaintToken = -1;
|
||||
if (token === this._continuity.token) {
|
||||
this._continuityPaintToken = -1;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._renderSnapshotAt = Date.now();
|
||||
if (this._candidateDeviceSnapshot) this._visibleDeviceSnapshot = this._candidateDeviceSnapshot;
|
||||
this._candidateDeviceSnapshot = null;
|
||||
this._stagedDeviceSnapshotToken = -1;
|
||||
});
|
||||
}
|
||||
|
||||
private _onAssetLoaded = (raw: string): void => {
|
||||
this._signer.markLoaded(this.hass, raw);
|
||||
private _onAssetLoaded = (raw: string, paintedUrl: string): void => {
|
||||
this._signer.markLoaded(this.hass, raw, paintedUrl);
|
||||
this._continuity.note('asset-ready');
|
||||
this._continuityPaintToken = -1;
|
||||
if (this._continuity.state !== 'steady') this.requestUpdate();
|
||||
@@ -449,7 +554,6 @@ class HouseplanSpaceCard extends LitElement {
|
||||
}
|
||||
this._loading = true;
|
||||
this._reloadQueued = false;
|
||||
let loaded = false;
|
||||
try {
|
||||
const snap = await getConfig(this.hass, force);
|
||||
const configChanged = !this._snap
|
||||
@@ -480,14 +584,17 @@ class HouseplanSpaceCard extends LitElement {
|
||||
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._connectionWasLost = false;
|
||||
this._continuityDataReady = true;
|
||||
this._refreshDevices();
|
||||
} catch {
|
||||
/* keep any localStorage snapshot */
|
||||
} finally {
|
||||
this._loading = false;
|
||||
if (!loaded && !this._continuity.hasCompleteFrame) this._continuityDataReady = true;
|
||||
// A failed signing/decode attempt is still a completed candidate attempt;
|
||||
// the paint barrier decides whether to keep the stale frame or show the
|
||||
// bounded fallback. Never leave the state machine waiting without a timer.
|
||||
this._continuityDataReady = true;
|
||||
this.requestUpdate();
|
||||
if (this._reloadQueued) {
|
||||
const forceAgain = this._forceReloadQueued;
|
||||
@@ -558,7 +665,8 @@ class HouseplanSpaceCard extends LitElement {
|
||||
|
||||
/** Redacted lifecycle diagnostics shared with the full card's sampler. */
|
||||
public houseplanContinuityTrace(): readonly import('./visual-continuity').ContinuityTraceEvent[] {
|
||||
return this._continuity.trace;
|
||||
return [...this._continuityHistory, ...this._continuity.trace].slice(-80)
|
||||
.map((event) => ({ ...event, ...(event.stage ? { stage: [...event.stage] as [number, number] } : {}) }));
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
@@ -569,8 +677,9 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return this._errorCard(t(this._lang, 'space_card.loading'));
|
||||
}
|
||||
const spaceId = this._config.space;
|
||||
const deviceSnapshot = this._renderDeviceSnapshot;
|
||||
const stage = renderSpaceStatic({
|
||||
hass: this.hass,
|
||||
hass: deviceSnapshot?.hass || this.hass,
|
||||
cfg,
|
||||
layout: this._snap?.layout || {},
|
||||
spaceId,
|
||||
@@ -580,12 +689,23 @@ class HouseplanSpaceCard extends LitElement {
|
||||
// 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,
|
||||
registry: deviceSnapshot
|
||||
? {
|
||||
revision: deviceSnapshot.sourceSequence,
|
||||
authoritative: false,
|
||||
access: 'limited' as const,
|
||||
entities: { ...deviceSnapshot.hass.entities },
|
||||
devices: { ...deviceSnapshot.hass.devices },
|
||||
lastSuccess: deviceSnapshot.capturedAt,
|
||||
}
|
||||
: haRegistrySnapshot(this.hass),
|
||||
devices: [...(deviceSnapshot?.devices || this._devices)],
|
||||
presentations: deviceSnapshot?.presentations,
|
||||
activityRuntime: this._activityRuntime,
|
||||
liveStates: this._config.live_states !== false,
|
||||
showTemperature: this._config.show_temperature !== false,
|
||||
showSignal: this._config.show_signal !== false,
|
||||
inert: this._continuity.overlayBlocksInteraction,
|
||||
});
|
||||
if (!stage) {
|
||||
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
|
||||
@@ -601,6 +721,7 @@ class HouseplanSpaceCard extends LitElement {
|
||||
data-continuity-state=${this._continuity.state}
|
||||
data-continuity-token=${this._continuity.token}
|
||||
data-frame-fingerprint=${this._continuity.frameFingerprint || nothing}
|
||||
data-device-snapshot-sequence=${deviceSnapshot?.sourceSequence ?? nothing}
|
||||
data-recovery-reason=${recoveryReason || nothing}>
|
||||
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
|
||||
<div class="hp-static-body">
|
||||
|
||||
+12
-6
@@ -22,8 +22,9 @@ import type { DevItem, ServerConfig } from './types';
|
||||
import { physicalBodies } from './physical-geometry';
|
||||
import { activeRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
|
||||
import {
|
||||
resolveDevicePresentation, type PresentationActivityRuntime,
|
||||
resolveDevicePresentation, type PresentationActivityRuntime, type ResolvedDevicePresentation,
|
||||
} from './device-presentation';
|
||||
import { presentationSnapshotKey } from './render-device-snapshot';
|
||||
import { deviceFaceStyle, renderDeviceFace } from './device-face';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
|
||||
@@ -46,6 +47,7 @@ export interface StaticRenderOpts {
|
||||
/** Optional roster prepared by the card so its activity runtime sees the same instances. */
|
||||
devices?: DevItem[];
|
||||
activityRuntime?: ReadonlyMap<string, PresentationActivityRuntime>;
|
||||
presentations?: ReadonlyMap<string, ResolvedDevicePresentation>;
|
||||
liveStates?: boolean;
|
||||
showTemperature?: boolean;
|
||||
showSignal?: boolean;
|
||||
@@ -57,7 +59,9 @@ export interface StaticRenderOpts {
|
||||
*/
|
||||
displayUrl?: (raw: string) => string;
|
||||
/** Marks a protected backdrop as loaded/paintable for continuity barriers. */
|
||||
assetLoaded?: (raw: string) => void;
|
||||
assetLoaded?: (raw: string, paintedUrl: string) => void;
|
||||
/** Recovery overlay owns input while an atomic candidate is being painted. */
|
||||
inert?: boolean;
|
||||
}
|
||||
|
||||
export interface StaticDeviceBuildOpts {
|
||||
@@ -235,10 +239,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const p = markerPos(d, o.layout, o.cfg, defPos, space);
|
||||
const left = ((p.x - vb[0]) / vb[2]) * 100;
|
||||
const top = ((p.y - vb[1]) / vb[3]) * 100;
|
||||
const presentation = resolveDevicePresentation(planHass, d, {
|
||||
const showLqi = disp.showLqi ?? (o.showSignal !== false);
|
||||
const presentation = o.presentations?.get(presentationSnapshotKey(d.id, showLqi))
|
||||
|| resolveDevicePresentation(planHass, d, {
|
||||
liveStates: o.liveStates !== false,
|
||||
showTemperature: o.showTemperature !== false,
|
||||
showSignal: disp.showLqi ?? (o.showSignal !== false),
|
||||
showSignal: showLqi,
|
||||
activityRuntime: o.activityRuntime?.get(d.id),
|
||||
sourceDetails: false,
|
||||
});
|
||||
@@ -296,7 +302,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const wallStroke = disp.color || '#607d8b';
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}">
|
||||
<div class="hp-static-stage" ?inert=${!!o.inert} style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${wallUnion ? svg`<defs>
|
||||
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse" width="8" height="8"
|
||||
@@ -311,7 +317,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)}
|
||||
@load=${() => o.assetLoaded?.(space.bg!.href, bgHref)}
|
||||
transform=${space.bg!.angle
|
||||
? `rotate(${space.bg!.angle} ${space.bg!.x + space.bg!.w / 2} ${space.bg!.y + space.bg!.h / 2})`
|
||||
: nothing}
|
||||
|
||||
+6
-1
@@ -325,7 +325,12 @@ export const cardStyles = css`
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-6);
|
||||
box-sizing: border-box;
|
||||
background: var(--ha-card-background, var(--card-background-color, #111));
|
||||
/* The final solid layer guarantees an opaque recovery surface even when
|
||||
a custom HA theme exposes its card colour as rgba(). */
|
||||
background:
|
||||
linear-gradient(var(--ha-card-background, var(--card-background-color, #111)),
|
||||
var(--ha-card-background, var(--card-background-color, #111))),
|
||||
#111;
|
||||
color: var(--primary-text-color, #fff);
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
|
||||
+38
-25
@@ -169,7 +169,12 @@ export class VisualContinuityController {
|
||||
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; }
|
||||
get trace(): readonly ContinuityTraceEvent[] {
|
||||
return this._trace.map((event) => ({
|
||||
...event,
|
||||
...(event.stage ? { stage: [...event.stage] as [number, number] } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Redacted card-owned lifecycle markers; never pass ids or urls here. */
|
||||
note(event: string, detail: Partial<ContinuityTraceEvent> = {}): void {
|
||||
@@ -274,13 +279,24 @@ export class VisualContinuityController {
|
||||
*/
|
||||
async commitAfterPaint(token: number, hooks: PaintBarrierHooks): Promise<boolean> {
|
||||
if (this._disposed || token !== this._token) return false;
|
||||
let barrierActive = true;
|
||||
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();
|
||||
// Candidate data readiness starts the bounded barrier. Stage dimensions
|
||||
// and protected-image decode may become ready a little later, so keep
|
||||
// sampling them inside the same two-second budget instead of returning
|
||||
// early and leaving an opaque recovery overlay without a running timer.
|
||||
while (barrierActive && !this._disposed && token === this._token) {
|
||||
if (hooks.stageValid() && hooks.assetsReady()) {
|
||||
await this.nextFrame();
|
||||
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) continue;
|
||||
await this.nextFrame();
|
||||
if (token === this._token && hooks.stageValid() && hooks.assetsReady()) return true;
|
||||
} else {
|
||||
await this.nextFrame();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
let timeout = 0;
|
||||
const timed = new Promise<{ ready: false; timedOut: true }>((resolve) => {
|
||||
@@ -292,22 +308,10 @@ export class VisualContinuityController {
|
||||
barrier.then((ready) => ({ ready, timedOut: false as const })),
|
||||
timed,
|
||||
]);
|
||||
barrierActive = false;
|
||||
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 && !outcome.timedOut) return false;
|
||||
if (!outcome.ready) {
|
||||
this.record('paint-barrier-timeout');
|
||||
if (this._hasCompleteFrame) {
|
||||
@@ -315,7 +319,10 @@ export class VisualContinuityController {
|
||||
this.clearOverlay();
|
||||
} else {
|
||||
this._state = 'recovery-error';
|
||||
if (this._overlayPhase === 'none') this.showOverlayNow();
|
||||
// A throttled/background tab may reach the terminal timeout while the
|
||||
// entry rAF is still pending. Recovery-error is a terminal fallback,
|
||||
// so never leave it in a transparent/intermediate animation phase.
|
||||
if (this._overlayPhase !== 'opaque') this.showOverlayNow();
|
||||
}
|
||||
this.changed();
|
||||
return false;
|
||||
@@ -346,7 +353,8 @@ export class VisualContinuityController {
|
||||
this._recoveryReason = reason;
|
||||
this._overlayTimer = this.clock.setTimeout(() => {
|
||||
this._overlayTimer = 0;
|
||||
if (this._hasCompleteFrame || this._state !== 'overlay-pending') return;
|
||||
if (this._hasCompleteFrame
|
||||
|| (this._state !== 'overlay-pending' && this._state !== 'candidate-ready')) return;
|
||||
this._state = 'overlay-visible';
|
||||
this._overlayPhase = 'entering';
|
||||
this.record('overlay-enter');
|
||||
@@ -431,7 +439,10 @@ export class VisualContinuityController {
|
||||
export function contentFingerprint(value: unknown): string {
|
||||
let a = 0x811c9dc5;
|
||||
let b = 0x9e3779b9;
|
||||
const seen = new WeakSet<object>();
|
||||
// This is the active recursion stack, not a global "seen" set. A shared but
|
||||
// acyclic child must hash by value at every occurrence; only an ancestor
|
||||
// reference is a cycle.
|
||||
const stack = new WeakSet<object>();
|
||||
const feed = (text: string): void => {
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const code = text.charCodeAt(index);
|
||||
@@ -449,12 +460,13 @@ export function contentFingerprint(value: unknown): string {
|
||||
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 (stack.has(object)) { feed('[cycle]'); return; }
|
||||
stack.add(object);
|
||||
if (Array.isArray(node)) {
|
||||
feed('[');
|
||||
for (const item of node) visit(item);
|
||||
feed(']');
|
||||
stack.delete(object);
|
||||
return;
|
||||
}
|
||||
feed('{');
|
||||
@@ -463,6 +475,7 @@ export function contentFingerprint(value: unknown): string {
|
||||
visit((node as Record<string, unknown>)[key]);
|
||||
}
|
||||
feed('}');
|
||||
stack.delete(object);
|
||||
};
|
||||
visit(value);
|
||||
return `${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`;
|
||||
|
||||
Reference in New Issue
Block a user