fix: make card resource recovery observable

Issue: #462
User-Visible: yes
This commit is contained in:
Matysh
2026-09-05 16:04:54 +03:00
parent a4fe939f63
commit fcf0ce80eb
74 changed files with 4739 additions and 450 deletions
+57 -20
View File
@@ -284,6 +284,10 @@ import {
visualFrameFingerprint,
type PageVisibilitySignal,
} from './visual-continuity';
import { cardVersionReloadSafetySnapshot, createCardVersionRecovery, renderVersionBanner,
adoptCardConfigCapabilities, getAuthoritativeCardConfig,
type AuthoritativeConfigResponse, type ConfigCapabilitiesCardPort,
type VersionRecoveryCardPort } from './version-recovery-card';
import { PointerModalityController } from './pointer-modality';
import {
entityVisualSample, entityVisualSamplesForDevice,
@@ -708,7 +712,11 @@ export class HouseplanCard extends LitElement {
stateChanged: (state) => this._editorRuntimeStateChanged(state),
failed: (error, info) => {
console.error('[houseplan] unable to load editor runtime', error);
this._showToast(lazyLoadFailureMessage((key) => this._t(key), info));
// A terminal fingerprint failure asks for the same page reload as the
// eager version notice. Keep network/non-terminal retry advice intact.
if (!(info.terminal && this._versionRecovery.hasCurrentMismatchNotice)) {
this._showToast(lazyLoadFailureMessage((key) => this._t(key), info));
}
},
});
@@ -736,7 +744,9 @@ export class HouseplanCard extends LitElement {
},
failed: (error, info) => {
console.error('[houseplan] unable to load onboarding runtime', error);
this._showToast(lazyLoadFailureMessage((key) => this._t(key), info));
if (!(info.terminal && this._versionRecovery.hasCurrentMismatchNotice)) {
this._showToast(lazyLoadFailureMessage((key) => this._t(key), info));
}
},
});
@@ -1031,6 +1041,7 @@ export class HouseplanCard extends LitElement {
} else if (event.matches && this._modeTransition.active) {
this._cancelModeTransition(true);
}
this._syncVersionRecovery();
this.requestUpdate();
};
/** Last editor kept in the collapsing chrome so leaving it can animate out. */
@@ -2037,6 +2048,16 @@ export class HouseplanCard extends LitElement {
private _preflightClipboardFallback: string | null = null;
/** #295: integration version from houseplan/config/get; null on old backends. */
private _haIntegrationVersion: string | null = null;
/** #462: eager full-card recovery; the static space card never owns one. */
private readonly _versionRecovery = createCardVersionRecovery(this as unknown as VersionRecoveryCardPort);
private _syncVersionRecovery(): void {
this._versionRecovery.update({
frontendVersion: CARD_VERSION,
backendVersion: this._haIntegrationVersion,
kiosk: this._config?.kiosk === true,
reducedMotion: this._reducedMotion,
});
}
/** #423: support protocol capability from config/get; never persisted. */
private _haSupportApi: number | null = null;
/** #51: custom-image protocol capability from config/get; fail closed. */
@@ -2524,6 +2545,8 @@ export class HouseplanCard extends LitElement {
this._motionMedia = window.matchMedia?.('(prefers-reduced-motion: reduce)');
this._reducedMotion = !!this._motionMedia?.matches;
this._motionMedia?.addEventListener?.('change', this._onMotionChange);
this._syncVersionRecovery();
this._versionRecovery.connect();
svgScreenBlendSupported(this.ownerDocument).then((supported) => {
if (supported === this._glowScreenBlend) return;
this._glowScreenBlend = supported;
@@ -2575,6 +2598,9 @@ export class HouseplanCard extends LitElement {
}
public disconnectedCallback(): void {
// Stop the independent kiosk timer before any teardown flush can mutate
// config/layout state or this detached instance can reload the document.
this._versionRecovery.disconnect();
this._liveRt?.dispose();
this._editorRuntime?._disposeLiveEditor();
this._clearRoomFocus(true);
@@ -3140,6 +3166,7 @@ export class HouseplanCard extends LitElement {
if (this._warmLongReturn) this._beginResumeSettle();
this._warmLongReturn = false;
}
this._syncVersionRecovery();
}
/**
@@ -3575,6 +3602,10 @@ export class HouseplanCard extends LitElement {
return !!stage && stage.clientWidth > 0 && stage.clientHeight > 0;
}
private _versionReloadSafetySnapshot() {
return cardVersionReloadSafetySnapshot(this as unknown as VersionRecoveryCardPort);
}
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
@@ -3719,6 +3750,15 @@ export class HouseplanCard extends LitElement {
</div>`;
}
/** One patchable seam shared by trusted manual and guarded automatic reload. */
private _reloadDocument(): void {
this.ownerDocument.defaultView?.location.reload();
}
private _renderVersionBanner() {
return renderVersionBanner(this as unknown as VersionRecoveryCardPort, this._versionRecovery);
}
private _renderEditorRuntimeLoading(): TemplateResult | typeof nothing {
if (!this._editorRuntimeLoadingVisible) return nothing;
return html`<div class="editorloading" role="status" aria-live="polite"
@@ -4087,19 +4127,7 @@ export class HouseplanCard extends LitElement {
* epoch; equal revisions never hide changed content (#73 §9.4).
*/
private _adoptConfigCapabilities(response: unknown): void {
const capabilities = response && typeof response === 'object'
? response as Partial<Record<
'integration_version' | 'support_api' | 'decor_assets_api', unknown
>> : {};
this._haIntegrationVersion = typeof capabilities.integration_version === 'string'
? capabilities.integration_version : this._haIntegrationVersion;
const supportApi = capabilities.support_api;
// Every successful config/get is authoritative. Missing or malformed data
// after a backend downgrade must revoke a capability learned earlier.
this._haSupportApi = typeof supportApi === 'number' && Number.isSafeInteger(supportApi)
? supportApi : null;
this._haDecorAssetsApi = capabilities.decor_assets_api === DECOR_ASSETS_API_VERSION
? DECOR_ASSETS_API_VERSION : null;
adoptCardConfigCapabilities(this as unknown as ConfigCapabilitiesCardPort, response);
}
private async _syncDecorAssets(cfg: ServerConfig | null): Promise<void> {
@@ -4195,6 +4223,10 @@ export class HouseplanCard extends LitElement {
return true;
}
private _getAuthoritativeConfig(): Promise<AuthoritativeConfigResponse> {
return getAuthoritativeCardConfig(this as unknown as ConfigCapabilitiesCardPort);
}
private async _loadFromServer(): Promise<void> {
this._loading = true;
this._loadTries++;
@@ -4202,7 +4234,7 @@ export class HouseplanCard extends LitElement {
const hadViewport = !!this._view;
try {
const [cfgResp, layResp] = await Promise.all([
this.hass.callWS({ type: 'houseplan/config/get' }),
this._getAuthoritativeConfig(),
this.hass.callWS({ type: 'houseplan/layout/get' }),
]);
const candidateConfig = cfgResp?.config && Array.isArray(cfgResp.config.spaces)
@@ -4394,7 +4426,7 @@ export class HouseplanCard extends LitElement {
}
this._beginContinuityCandidate('config-reload', false);
try {
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
const resp = await this._getAuthoritativeConfig();
const candidateConfig = resp?.config && Array.isArray(resp.config.spaces)
? resp.config as ServerConfig : null;
const configChanged = contentFingerprint(candidateConfig)
@@ -4879,8 +4911,6 @@ export class HouseplanCard extends LitElement {
this._layoutContentFingerprint = fingerprint;
this._layoutRev = resp?.rev ?? this._layoutRev;
this._canOptimizeUndo = !!resp?.can_optimize_undo;
this._haIntegrationVersion = typeof resp?.integration_version === 'string'
? resp.integration_version : this._haIntegrationVersion;
this._undoKind = (resp?.undo_kind || null) as any;
this._cacheSnapshot();
this.requestUpdate();
@@ -5571,6 +5601,13 @@ export class HouseplanCard extends LitElement {
this._showToast(this._t('toast.ha_disabled_action'));
return;
}
// The native HA dialog has no reliable close event for this card. Its
// stale entity field cannot be a reload guard, so every successful open —
// including keyboard activation — extends the shared kiosk interaction
// pause used by the carousel and #462 recovery controller.
if (this._kiosk) {
this._cyclePausedUntil = Math.max(this._cyclePausedUntil, Date.now() + 60000);
}
this._nativeMoreInfoEntity = entityId;
fireEvent(this, 'hass-more-info', { entityId });
}
@@ -11239,7 +11276,7 @@ export class HouseplanCard extends LitElement {
/** One stable Lit template site lets a nested `noChange` retain the body
* while the independent confirmation child is removed during a warm gate. */
private _renderRoot(body: TemplateResult | typeof noChange): TemplateResult {
return html`${body}${this._renderDangerConfirm()}`;
return html`${body}${this._renderVersionBanner()}${this._renderDangerConfirm()}`;
}
protected render(): TemplateResult | typeof nothing | typeof noChange {
+9 -7
View File
@@ -24,6 +24,7 @@ import type { HpConfirmRequest } from './danger-confirm';
import './hp-color-opacity';
import type { ColorPickerLabels } from './hp-color-opacity';
import './hp-help';
import type { AuthoritativeConfigResponse } from './version-recovery-card';
import './hp-device-preview'; import './hp-zigbee-topology-settings';
import {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
@@ -952,6 +953,7 @@ export interface HouseplanEditorHostPort {
} | null;
_gearPtCache: WeakMap<number[][], number[]>;
_geometryHistory: CommandStack<SpaceGeometryState>;
_getAuthoritativeConfig: () => Promise<AuthoritativeConfigResponse>;
_glowRadiusCm: number;
_glowRadiusPlaceholder: string;
_gridPitch: number;
@@ -8931,7 +8933,7 @@ public async _deleteSpace(): Promise<void> {
expected_layout_rev: this.host._layoutRev,
});
const [configResponse, layoutResponse] = await Promise.all([
this.host.hass.callWS({ type: 'houseplan/config/get' }),
this.host._getAuthoritativeConfig(),
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
]);
this.host._adoptStructuralResponses(configResponse, layoutResponse);
@@ -9811,16 +9813,16 @@ public async _undoPlanOptimization(): Promise<void> {
expected_layout_rev: this.host._layoutRev,
});
const [cfgResp, layResp] = await Promise.all([
this.host.hass.callWS({ type: 'houseplan/config/get' }),
this.host._getAuthoritativeConfig(),
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
]);
this.host._serverCfg = cfgResp?.config || this.host._serverCfg;
this.host._cfgRev = cfgResp?.rev ?? this.host._cfgRev;
this.host._layout = layResp?.layout || this.host._layout;
// config/get is the sole authority for runtime capabilities, including
// integration_version. Reuse the full-card adopter instead of leaving
// this direct optimization-undo path with stale version state (#462).
this.host._adoptStructuralResponses(cfgResp, layResp);
this.host._geometryHistory.clear();
this.host._cancelDeviceDrag();
this.host._devicePositionHistory.clear();
this.host._layoutRev = layResp?.rev ?? this.host._layoutRev;
this.host._canOptimizeUndo = false;
this.host._undoKind = null;
this.host._cfgEpoch++;
@@ -9973,7 +9975,7 @@ public async _applyBackupImport(): Promise<void> {
confirm_missing_content: d.confirmMissing,
});
const [configResponse, layoutResponse] = await Promise.all([
this.host.hass.callWS({ type: 'houseplan/config/get' }),
this.host._getAuthoritativeConfig(),
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
]);
this.host._adoptStructuralResponses(configResponse, layoutResponse);
+1 -1
View File
@@ -470,7 +470,7 @@ export class HouseplanOnboardingRuntime {
expected_layout_rev: this.host._layoutRev,
});
const [configResponse, layoutResponse] = await Promise.all([
this.host.hass.callWS({ type: 'houseplan/config/get' }),
this.host._getAuthoritativeConfig(),
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
]);
this.host._adoptStructuralResponses(configResponse, layoutResponse);
+5
View File
@@ -10,6 +10,11 @@
"editor.load_failed": "Der Editor konnte nicht geladen werden.",
"editor.refresh_advice": "Seite aktualisieren und erneut versuchen.",
"editor.retry_advice": "Verbindung prüfen und erneut drücken.",
"version_mismatch.title": "Die House Plan-Versionen stimmen nicht überein",
"version_mismatch.body": "Falls Home Assistant noch neu startet, warten Sie den Abschluss ab und laden Sie dann diese Seite neu.",
"version_mismatch.frontend": "Karte",
"version_mismatch.backend": "Integration",
"version_mismatch.reload": "Seite neu laden",
"color_picker.title": "Farbwähler",
"color_picker.hue": "Farbton",
"color_picker.saturation": "Sättigung",
+5
View File
@@ -10,6 +10,11 @@
"editor.load_failed": "Could not load the editor.",
"editor.refresh_advice": "Refresh the page and try again.",
"editor.retry_advice": "Check your connection and press again.",
"version_mismatch.title": "House Plan versions do not match",
"version_mismatch.body": "If Home Assistant is still restarting, let it finish, then reload this page.",
"version_mismatch.frontend": "Card",
"version_mismatch.backend": "Integration",
"version_mismatch.reload": "Reload page",
"color_picker.title": "Color picker",
"color_picker.hue": "Hue",
"color_picker.saturation": "Saturation",
+5
View File
@@ -10,6 +10,11 @@
"editor.load_failed": "Impossible de charger l'éditeur.",
"editor.refresh_advice": "Actualisez la page et réessayez.",
"editor.retry_advice": "Vérifiez votre connexion et appuyez à nouveau.",
"version_mismatch.title": "Les versions de House Plan ne correspondent pas",
"version_mismatch.body": "Si Home Assistant redémarre encore, attendez la fin, puis rechargez cette page.",
"version_mismatch.frontend": "Carte",
"version_mismatch.backend": "Intégration",
"version_mismatch.reload": "Recharger la page",
"color_picker.title": "Sélecteur de couleur",
"color_picker.hue": "Teinte",
"color_picker.saturation": "Saturation",
+5
View File
@@ -10,6 +10,11 @@
"editor.load_failed": "Не удалось загрузить редактор.",
"editor.refresh_advice": "Обновите страницу и повторите попытку.",
"editor.retry_advice": "Проверьте сеть и нажмите ещё раз.",
"version_mismatch.title": "Версии House Plan не совпадают",
"version_mismatch.body": "Если Home Assistant ещё перезапускается, дождитесь завершения, затем перезагрузите эту страницу.",
"version_mismatch.frontend": "Карточка",
"version_mismatch.backend": "Интеграция",
"version_mismatch.reload": "Перезагрузить страницу",
"color_picker.title": "Выбор цвета",
"color_picker.hue": "Оттенок",
"color_picker.saturation": "Насыщенность",
+87
View File
@@ -33,6 +33,10 @@ export const baseStyles = css`
--shadow-2: 0 6px 22px rgba(0, 0, 0, 0.45);
--shadow-3: 0 8px 30px rgba(0, 0, 0, 0.5);
}
:host(houseplan-card) {
display: block;
position: relative;
}
ha-card {
overflow: visible; /* overflow:hidden breaks position:sticky on the header */
}
@@ -58,6 +62,85 @@ export const baseStyles = css`
.empty .btn {
margin-top: var(--sp-4);
}
.version-recovery {
position: absolute;
z-index: 72;
left: max(var(--sp-5), env(safe-area-inset-left));
right: max(var(--sp-5), env(safe-area-inset-right));
bottom: max(var(--sp-5), env(safe-area-inset-bottom));
width: auto;
max-width: 430px;
margin-left: auto;
box-sizing: border-box;
pointer-events: none;
color: var(--hp-txt);
}
.version-recovery.phase-visible {
animation: hp-version-recovery-in 160ms ease-out both;
}
.version-recovery.phase-leaving {
animation: hp-version-recovery-out 140ms ease-in both;
}
.version-recovery-card {
pointer-events: auto;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-5);
padding: var(--sp-5);
border: 1px solid var(--hp-accent);
border-radius: var(--rad-l);
background: var(--card-background-color, #16212e);
box-shadow: var(--shadow-2);
}
.phase-leaving .version-recovery-card {
pointer-events: none;
}
.version-recovery-card > ha-icon {
align-self: start;
color: var(--hp-accent);
}
.version-recovery-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
font-size: var(--fs-m);
line-height: 1.35;
}
.version-recovery-copy > span {
color: var(--hp-muted);
}
.version-recovery-versions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-5);
}
.version-recovery-versions span,
.version-recovery-versions b {
overflow-wrap: anywhere;
}
.version-recovery-reload {
min-height: 44px;
white-space: normal;
}
@keyframes hp-version-recovery-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes hp-version-recovery-out {
from { opacity: 1; }
to { opacity: 0; }
}
@media (max-width: 520px) {
.version-recovery-card {
grid-template-columns: auto minmax(0, 1fr);
}
.version-recovery-reload {
grid-column: 1 / -1;
width: 100%;
}
}
@keyframes fixedfloor-spin {
to { transform: rotate(360deg); }
}
@@ -96,6 +179,10 @@ export const baseStyles = css`
animation: none;
opacity: 0.7;
}
.version-recovery.phase-visible,
.version-recovery.phase-leaving {
animation: none;
}
}
@keyframes hp-pulse-short {
0% { transform: scale(1); opacity: 0.55; }
+223
View File
@@ -0,0 +1,223 @@
/** Full-card adapter for the pure runtime-version controller (#462). */
import { html, nothing, type TemplateResult } from 'lit';
import type { I18nKey } from './i18n';
import type { ServerConfig } from './types';
import {
fetchAuthoritativeConfig,
normalizeRuntimeVersion,
VersionRecoveryController,
type VersionBannerNotice,
type VersionReloadSafetySnapshot,
type VersionRecoveryStorage,
} from './version-recovery';
import { DECOR_ASSETS_API_VERSION } from './decor-assets';
interface PendingCollection { readonly size: number }
interface PendingDebounce { pending(): boolean }
interface VersionRecoveryContinuity {
readonly hasCompleteFrame: boolean;
readonly state: string;
readonly overlayBlocksInteraction: boolean;
}
export interface ConfigCapabilitiesCardPort {
readonly hass: { callWS<T>(message: { type: string }): Promise<T> };
_haIntegrationVersion: string | null;
_haSupportApi: number | null;
_haDecorAssetsApi: number | null;
_syncVersionRecovery(): void;
}
/** Fields returned by houseplan/config/get and consumed by full-card flows. */
export interface AuthoritativeConfigResponse {
readonly config?: ServerConfig | null;
readonly rev?: number;
readonly can_write?: boolean;
readonly can_optimize_undo?: boolean;
readonly undo_kind?: string | null;
readonly integration_version?: unknown;
readonly support_api?: unknown;
readonly decor_assets_api?: unknown;
readonly virtual_lights?: unknown;
}
export function adoptCardConfigCapabilities(
host: ConfigCapabilitiesCardPort,
response: unknown,
): void {
const capabilities = response && typeof response === 'object'
? response as Partial<Record<
'integration_version' | 'support_api' | 'decor_assets_api', unknown
>> : {};
host._haIntegrationVersion = normalizeRuntimeVersion(capabilities.integration_version);
const supportApi = capabilities.support_api;
host._haSupportApi = typeof supportApi === 'number' && Number.isSafeInteger(supportApi)
? supportApi : null;
host._haDecorAssetsApi = capabilities.decor_assets_api === DECOR_ASSETS_API_VERSION
? DECOR_ASSETS_API_VERSION : null;
host._syncVersionRecovery();
}
export function getAuthoritativeCardConfig(
host: ConfigCapabilitiesCardPort,
): Promise<AuthoritativeConfigResponse> {
return fetchAuthoritativeConfig(
() => host.hass.callWS<AuthoritativeConfigResponse>({ type: 'houseplan/config/get' }),
(response) => adoptCardConfigCapabilities(host, response),
);
}
/** Runtime-shaped port: types stay here instead of growing the card core. */
export interface VersionRecoveryCardPort {
readonly ownerDocument: Document;
readonly isConnected: boolean;
readonly _config: { kiosk?: boolean } | null;
readonly _mode: string;
readonly _editing: boolean;
readonly _loadOk: boolean;
readonly _loading: boolean;
readonly _continuityDataReady: boolean;
readonly _resumeSettling: boolean;
readonly _connectionWasLost: boolean;
readonly _booting: boolean;
readonly _bootFading: boolean;
readonly _bootSettling: boolean;
readonly _bootSoft: boolean;
readonly _continuity: VersionRecoveryContinuity;
readonly _stageEl: { readonly clientWidth: number; readonly clientHeight: number } | null;
readonly _pointers: PendingCollection;
readonly _touchContacts: PendingCollection;
readonly _touchSequenceMultitouch: boolean;
readonly _roomPointer: unknown;
readonly _panStart: unknown;
readonly _panLock: unknown;
readonly _pinchStart: unknown;
readonly _swipeStart: unknown;
readonly _tabDrag: unknown;
readonly _tabDragRelease: unknown;
readonly _drag: unknown;
readonly _deviceDrag: unknown;
readonly _rlResize: unknown;
readonly _resize: { readonly dragging?: boolean } | null;
readonly _physicalDrag: unknown;
readonly _physicalRotate: unknown;
readonly _opDrag: unknown;
readonly _decorDraft: unknown;
readonly _decorMove: unknown;
readonly _dtDrag: unknown;
readonly _bdDrag: unknown;
readonly _furnTouchPending: unknown;
readonly _compassDrag: unknown;
readonly _viewportGestureDirty: boolean;
readonly _devicePositionBusy: boolean;
readonly _modeTransitionBusy: boolean;
readonly _cameraTransition: { readonly active: boolean };
readonly _slide: string;
readonly _warmModeRequest: number;
readonly _writesPending: number;
readonly _saveConfigDebounced: PendingDebounce;
readonly _pendingPhysicalWrites: PendingCollection;
readonly _persistLayout: PendingDebounce;
readonly _dirtyPos: PendingCollection;
readonly _sentPos: PendingCollection;
readonly _cyclePausedUntil: number;
readonly _zoom: number;
readonly _editorSecondaryDialogBlocked: boolean;
readonly _partitionDeleteDialog: unknown;
readonly _roomDeleteDialog: unknown;
readonly _backdropGuard: unknown;
readonly _vacFit: unknown;
readonly _editorSecondary: { readonly hasOpenGroup?: boolean } | null;
readonly _furnPalette: unknown;
readonly _decorImagePalette: unknown;
_reloadDocument(): void;
requestUpdate(): unknown;
_t(key: I18nKey): string;
}
export function createCardVersionRecovery(host: VersionRecoveryCardPort): VersionRecoveryController {
return new VersionRecoveryController({
clock: {
setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs),
clearTimeout: (handle) => window.clearTimeout(handle),
},
storage: (): VersionRecoveryStorage | null => host.ownerDocument.defaultView?.sessionStorage ?? null,
safety: () => cardVersionReloadSafetySnapshot(host),
reload: () => host._reloadDocument(),
changed: () => { host.requestUpdate(); },
});
}
export function cardVersionReloadSafetySnapshot(
host: VersionRecoveryCardPort,
): VersionReloadSafetySnapshot {
const stage = host._stageEl;
const initialFrameSettled = host._loadOk && !host._loading
&& host._continuityDataReady && !host._resumeSettling && !host._connectionWasLost
&& !host._booting && !host._bootFading && !host._bootSettling && !host._bootSoft
&& host._continuity.hasCompleteFrame && host._continuity.state === 'steady'
&& !host._continuity.overlayBlocksInteraction
&& !!stage && stage.clientWidth > 0 && stage.clientHeight > 0;
const gesturesIdle = host._pointers.size === 0 && host._touchContacts.size === 0
&& !host._touchSequenceMultitouch && !host._roomPointer && !host._panStart && !host._panLock
&& !host._pinchStart && !host._swipeStart && !host._tabDrag && !host._tabDragRelease
&& !host._drag && !host._deviceDrag && !host._rlResize && !host._resize?.dragging
&& !host._physicalDrag && !host._physicalRotate && !host._opDrag && !host._decorDraft
&& !host._decorMove && !host._dtDrag && !host._bdDrag && !host._furnTouchPending
&& !host._compassDrag && !host._viewportGestureDirty && !host._devicePositionBusy
&& !host._modeTransitionBusy && !host._cameraTransition.active && !host._slide
&& host._warmModeRequest === 0;
const surfacesIdle = !host._editorSecondaryDialogBlocked && !host._partitionDeleteDialog
&& !host._roomDeleteDialog && !host._backdropGuard && !host._vacFit
&& !host._editorSecondary?.hasOpenGroup && !host._furnPalette && !host._decorImagePalette;
return {
connected: host.isConnected,
initialFrameSettled,
viewOnly: host._config?.kiosk === true && host._mode === 'view' && !host._editing,
surfacesIdle,
configWritesIdle: host._writesPending === 0 && !host._saveConfigDebounced.pending(),
physicalWritesIdle: host._pendingPhysicalWrites.size === 0,
layoutWritesIdle: !host._persistLayout.pending() && host._dirtyPos.size === 0
&& host._sentPos.size === 0 && !host._devicePositionBusy,
gesturesIdle,
interactionPauseElapsed: Date.now() >= host._cyclePausedUntil,
baseZoom: host._zoom <= 1.001,
};
}
export function renderVersionBanner(
host: VersionRecoveryCardPort,
controller: VersionRecoveryController,
notice: VersionBannerNotice | null = controller.banner,
): TemplateResult | typeof nothing {
if (!notice) return nothing;
const leaving = notice.phase === 'leaving';
const reload = (event: MouseEvent) => {
event.stopPropagation();
if (event.isTrusted && controller.hasCurrentMismatchNotice) host._reloadDocument();
};
const finish = (event: AnimationEvent) => {
if (event.target === event.currentTarget && event.animationName === 'hp-version-recovery-out') {
controller.finishBannerExit(notice.token);
}
};
return html`<div class="version-recovery phase-${notice.phase}"
data-version-recovery-target=${notice.backend} role="status"
aria-live="polite" aria-atomic="true" aria-hidden=${leaving ? 'true' : nothing}
?inert=${leaving} @animationend=${finish}>
<div class="version-recovery-card">
<ha-icon icon="mdi:update" aria-hidden="true"></ha-icon>
<div class="version-recovery-copy">
<strong>${host._t('version_mismatch.title')}</strong>
<span>${host._t('version_mismatch.body')}</span>
<span class="version-recovery-versions">
<span>${host._t('version_mismatch.frontend')}: <b>${notice.frontend}</b></span>
<span>${host._t('version_mismatch.backend')}: <b>${notice.backend}</b></span>
</span>
</div>
<button class="btn on version-recovery-reload" type="button" @click=${reload}>
${host._t('version_mismatch.reload')}
</button>
</div>
</div>`;
}
+331
View File
@@ -0,0 +1,331 @@
/**
* Runtime frontend/backend version reconciliation for the full House Plan card.
*
* This module deliberately has no DOM, Lit or Home Assistant dependency. The
* card supplies the clock, session storage, safety snapshot and reload seam;
* tests can therefore prove the reload-loop and lifecycle rules without a
* browser. Keeping it in the eager graph lets View/kiosk recover even when a
* stale lazy editor chunk cannot be imported (#462).
*/
export const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
export const VERSION_RECOVERY_CHECK_MS = 250;
export type VersionRelation =
| { kind: 'unknown' }
| { kind: 'equal'; frontend: string; backend: string }
| { kind: 'mismatch'; frontend: string; backend: string };
export interface VersionReloadSafetySnapshot {
connected: boolean;
initialFrameSettled: boolean;
viewOnly: boolean;
surfacesIdle: boolean;
configWritesIdle: boolean;
physicalWritesIdle: boolean;
layoutWritesIdle: boolean;
gesturesIdle: boolean;
interactionPauseElapsed: boolean;
baseZoom: boolean;
}
export interface VersionRecoveryInput {
frontendVersion: unknown;
backendVersion: unknown;
kiosk: boolean;
reducedMotion: boolean;
}
export interface VersionBannerNotice {
frontend: string;
backend: string;
phase: 'visible' | 'leaving';
/** Rejects a late animationend after the target or phase changed. */
token: number;
}
export interface VersionRecoveryStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface VersionRecoveryClock {
setTimeout(callback: () => void, delayMs: number): number;
clearTimeout(handle: number): void;
}
export interface VersionRecoveryHooks {
clock: VersionRecoveryClock;
storage: () => VersionRecoveryStorage | null | undefined;
safety: () => VersionReloadSafetySnapshot;
reload: () => void;
changed: () => void;
}
type ReloadAttemptState = 'fresh' | 'attempted' | 'unavailable';
type ClaimResult = 'claimed' | 'attempted' | 'unavailable';
/** Whitespace is not a version, and comparisons never infer SemVer ordering. */
export function normalizeRuntimeVersion(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized || null;
}
export function compareRuntimeVersions(frontend: unknown, backend: unknown): VersionRelation {
const normalizedFrontend = normalizeRuntimeVersion(frontend);
const normalizedBackend = normalizeRuntimeVersion(backend);
if (!normalizedFrontend || !normalizedBackend) return { kind: 'unknown' };
if (normalizedFrontend === normalizedBackend) {
return { kind: 'equal', frontend: normalizedFrontend, backend: normalizedBackend };
}
return { kind: 'mismatch', frontend: normalizedFrontend, backend: normalizedBackend };
}
/**
* A fulfilled config/get is authoritative even when a concurrently requested
* layout or a later asset preparation fails. Adoption therefore belongs to
* this individual promise, never after an enclosing Promise.all.
*/
export async function fetchAuthoritativeConfig<T>(
request: () => Promise<T>,
adopt: (response: T) => void,
): Promise<T> {
const response = await request();
adopt(response);
return response;
}
export function isVersionReloadSafe(snapshot: VersionReloadSafetySnapshot): boolean {
return snapshot.connected
&& snapshot.initialFrameSettled
&& snapshot.viewOnly
&& snapshot.surfacesIdle
&& snapshot.configWritesIdle
&& snapshot.physicalWritesIdle
&& snapshot.layoutWritesIdle
&& snapshot.gesturesIdle
&& snapshot.interactionPauseElapsed
&& snapshot.baseZoom;
}
/**
* Read the exact normalized backend target. Access to the sessionStorage
* getter itself may throw in privacy-restricted/webview contexts, hence the
* provider lives inside the try block as well.
*/
function attemptedTarget(
storageProvider: VersionRecoveryHooks['storage'],
target: string,
): ReloadAttemptState {
try {
const storage = storageProvider();
if (!storage) return 'unavailable';
return storage.getItem(VERSION_RELOAD_ATTEMPT_KEY) === target ? 'attempted' : 'fresh';
} catch {
return 'unavailable';
}
}
/**
* Atomic within one browser event loop: every card re-reads immediately before
* claiming, writes the target synchronously, and only then may reload.
*/
function claimReloadTarget(
storageProvider: VersionRecoveryHooks['storage'],
target: string,
): ClaimResult {
try {
const storage = storageProvider();
if (!storage) return 'unavailable';
if (storage.getItem(VERSION_RELOAD_ATTEMPT_KEY) === target) return 'attempted';
storage.setItem(VERSION_RELOAD_ATTEMPT_KEY, target);
return 'claimed';
} catch {
return 'unavailable';
}
}
const sameRelation = (a: VersionRelation, b: VersionRelation): boolean =>
a.kind === b.kind
&& (a.kind === 'unknown'
|| (b.kind !== 'unknown' && a.frontend === b.frontend && a.backend === b.backend));
const sameInput = (a: VersionRecoveryInput, b: VersionRecoveryInput): boolean =>
a.frontendVersion === b.frontendVersion
&& a.backendVersion === b.backendVersion
&& a.kiosk === b.kiosk
&& a.reducedMotion === b.reducedMotion;
export class VersionRecoveryController {
private _input: VersionRecoveryInput = {
frontendVersion: null,
backendVersion: null,
kiosk: false,
reducedMotion: false,
};
private _relation: VersionRelation = { kind: 'unknown' };
private _banner: VersionBannerNotice | null = null;
private _connected = false;
private _attemptTarget: string | null = null;
private _attemptState: ReloadAttemptState | null = null;
private _timer: number | undefined;
private _noticeToken = 0;
public constructor(private readonly hooks: VersionRecoveryHooks) {}
public get relation(): VersionRelation {
return this._relation;
}
public get banner(): VersionBannerNotice | null {
return this._banner;
}
/** True only for a currently actionable mismatch, never for an exit frame. */
public get hasCurrentMismatchNotice(): boolean {
return this._relation.kind === 'mismatch' && this._banner?.phase === 'visible';
}
public connect(): void {
if (this._connected) return;
this._connected = true;
// Another full card may have claimed this target while this one was away.
this._attemptTarget = null;
this._attemptState = null;
this._reconcile();
}
public disconnect(): void {
if (!this._connected && !this._banner) return;
this._connected = false;
this._cancelTimer();
// A detached element cannot be trusted to deliver animationend. Reconnect
// reconstructs the right notice from the retained semantic input.
if (this._banner) this._banner = null;
}
public update(next: VersionRecoveryInput): void {
const normalized: VersionRecoveryInput = {
frontendVersion: normalizeRuntimeVersion(next.frontendVersion),
backendVersion: normalizeRuntimeVersion(next.backendVersion),
kiosk: next.kiosk === true,
reducedMotion: next.reducedMotion === true,
};
const relation = compareRuntimeVersions(
normalized.frontendVersion,
normalized.backendVersion,
);
const semanticChanged = !sameInput(this._input, normalized)
|| !sameRelation(this._relation, relation);
this._input = normalized;
this._relation = relation;
if (semanticChanged) this._reconcile();
}
/** Complete only the exit animation that still owns this token. */
public finishBannerExit(token: number): void {
if (!this._banner || this._banner.phase !== 'leaving' || this._banner.token !== token) return;
this._banner = null;
this.hooks.changed();
}
private _reconcile(): void {
this._cancelTimer();
if (!this._connected || this._relation.kind !== 'mismatch') {
this._attemptTarget = null;
this._attemptState = null;
this._hideBanner();
return;
}
if (!this._input.kiosk) {
this._attemptTarget = null;
this._attemptState = null;
this._showBanner(this._relation.frontend, this._relation.backend);
return;
}
const target = this._relation.backend;
if (this._attemptTarget !== target || this._attemptState === null) {
this._attemptTarget = target;
this._attemptState = attemptedTarget(this.hooks.storage, target);
}
if (this._attemptState === 'fresh') {
this._hideBanner();
this._armTimer();
return;
}
this._showBanner(this._relation.frontend, target);
}
private _armTimer(): void {
if (!this._connected || this._timer !== undefined
|| this._relation.kind !== 'mismatch' || !this._input.kiosk
|| this._attemptState !== 'fresh') return;
this._timer = this.hooks.clock.setTimeout(() => {
this._timer = undefined;
this._tick();
}, VERSION_RECOVERY_CHECK_MS);
}
private _cancelTimer(): void {
if (this._timer === undefined) return;
this.hooks.clock.clearTimeout(this._timer);
this._timer = undefined;
}
private _tick(): void {
if (!this._connected || this._relation.kind !== 'mismatch'
|| !this._input.kiosk || this._attemptState !== 'fresh') return;
if (!isVersionReloadSafe(this.hooks.safety())) {
this._armTimer();
return;
}
const target = this._relation.backend;
const claim = claimReloadTarget(this.hooks.storage, target);
this._attemptTarget = target;
this._attemptState = claim === 'claimed' ? 'attempted' : claim;
if (claim !== 'claimed') {
this._showBanner(this._relation.frontend, target);
return;
}
// The attempt is already durable at this point. If a test seam, browser
// policy or embedding shell prevents navigation, the same instance falls
// back to the manual notice rather than silently trying again.
this._showBanner(this._relation.frontend, target);
try {
this.hooks.reload();
} catch {
// The target is already durably claimed. A hostile embedding shell or a
// test seam may reject navigation synchronously; manual recovery remains
// visible and this card must never arm a second automatic attempt.
}
}
private _showBanner(frontend: string, backend: string): void {
if (this._banner?.phase === 'visible'
&& this._banner.frontend === frontend && this._banner.backend === backend) return;
this._banner = {
frontend,
backend,
phase: 'visible',
token: ++this._noticeToken,
};
this.hooks.changed();
}
private _hideBanner(): void {
if (!this._banner) return;
if (this._input.reducedMotion) {
this._banner = null;
this.hooks.changed();
return;
}
if (this._banner.phase === 'leaving') return;
this._banner = { ...this._banner, phase: 'leaving', token: ++this._noticeToken };
this.hooks.changed();
}
}