mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix: make card resource recovery observable
Issue: #462 User-Visible: yes
This commit is contained in:
+57
-20
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Насыщенность",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user