fix: guard Area snapshot cleanup

Issue: #419
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-02 19:18:28 +03:00
parent ce3e92b22b
commit 489e289f79
28 changed files with 708 additions and 274 deletions
+94 -13
View File
@@ -42,12 +42,34 @@ export interface AreaRelocationResolution {
relocateIds: Set<string>;
}
export interface AreaSnapshotCleanupOptions {
snapshot: unknown;
authoritative: boolean;
revision: number;
registryDevices: Readonly<Record<string, unknown>>;
registryEntities: Readonly<Record<string, unknown>>;
liveStates?: Readonly<Record<string, unknown>>;
markers?: readonly Marker[];
previousCandidates?: ReadonlyMap<MarkerAreaBinding, number>;
}
export interface AreaSnapshotCleanupResolution {
/** Snapshot ids whose exact binding was absent in two qualifying revisions. */
removeIds: Set<string>;
/** Runtime-only first-absence evidence, keyed by exact binding. */
candidates: Map<MarkerAreaBinding, number>;
/** The caller should request one confirmation registry refresh. */
needsConfirmationRefresh: boolean;
}
export interface ResolveAreaRelocationsOptions {
devices: readonly DevItem[];
model: readonly SpaceModel[];
layout: AreaRelocationLayout;
snapshot: unknown;
authoritative: boolean;
/** Orphan cleanup is resolved from registry evidence, never from devices[]. */
cleanupSnapshotIds?: ReadonlySet<string>;
/** Stored layout coordinates are normalised; model room coordinates are render units. */
coordinateScale?: number;
}
@@ -129,6 +151,76 @@ function unresolved(id: string, reason: AreaRelocationReason): AreaRelocationDec
};
}
function bindingParts(binding: MarkerAreaBinding): ['device' | 'entity', string] {
const split = binding.indexOf(':');
return [binding.slice(0, split) as 'device' | 'entity', binding.slice(split + 1)];
}
/**
* Resolve destructive cleanup independently from the filtered presentation
* roster. One missing full-registry frame is evidence to re-check, not
* evidence to forget Area provenance.
*/
export function resolveAreaSnapshotCleanup(
options: AreaSnapshotCleanupOptions,
): AreaSnapshotCleanupResolution {
const snapshot = markerAreaSnapshotOf(options.snapshot);
const snapshotBindings = new Set(Object.values(snapshot).map((entry) => entry.binding));
const candidates = new Map<MarkerAreaBinding, number>();
for (const [binding, revision] of options.previousCandidates || []) {
if (validBinding(binding) && Number.isFinite(revision) && snapshotBindings.has(binding)) {
candidates.set(binding, revision);
}
}
const markerIds = new Set<string>();
const markerBindings = new Set<MarkerAreaBinding>();
for (const marker of options.markers || []) {
if (marker.removed) continue;
if (validText(marker.id)) markerIds.add(marker.id);
if (validBinding(marker.binding)) markerBindings.add(marker.binding);
}
const deviceIds = new Set(Object.keys(options.registryDevices || {}));
const entityIds = new Set(Object.keys(options.registryEntities || {}));
const liveEntityIds = new Set(Object.keys(options.liveStates || {}));
const idsByBinding = new Map<MarkerAreaBinding, string[]>();
for (const [id, entry] of Object.entries(snapshot)) {
const ids = idsByBinding.get(entry.binding) || [];
ids.push(id);
idsByBinding.set(entry.binding, ids);
}
const removeIds = new Set<string>();
let needsConfirmationRefresh = false;
for (const [binding, ids] of idsByBinding) {
const [kind, ref] = bindingParts(binding);
const exists = markerBindings.has(binding)
|| ids.some((id) => markerIds.has(id))
|| (kind === 'device' ? deviceIds.has(ref) : entityIds.has(ref) || liveEntityIds.has(ref));
if (exists) {
candidates.delete(binding);
continue;
}
// A technically successful empty response is not destructive evidence.
// Keep an earlier candidate but do not advance it or create a refresh loop.
const namespaceNonEmpty = kind === 'device' ? deviceIds.size > 0 : entityIds.size > 0;
if (!options.authoritative || !namespaceNonEmpty) continue;
const firstRevision = candidates.get(binding);
if (firstRevision === undefined) {
candidates.set(binding, options.revision);
needsConfirmationRefresh = true;
continue;
}
if (firstRevision === options.revision) continue;
for (const id of ids) removeIds.add(id);
}
return { removeIds, candidates, needsConfirmationRefresh };
}
/**
* Resolve registry Area transitions once per authoritative device/model rebuild.
* The function has no stores, DOM, HA calls or writes and is shared by both
@@ -142,19 +234,8 @@ export function resolveDeviceAreaRelocations(
if (!options.authoritative) return { decisions, relocateIds };
const snapshot = markerAreaSnapshotOf(options.snapshot);
const liveIds = new Set<string>();
const liveBindings = new Set<MarkerAreaBinding>();
for (const device of options.devices) {
if (validText(device.id)) liveIds.add(device.id);
if (validText(device.marker?.id)) liveIds.add(device.marker.id);
if ((device.bindingKind === 'device' || device.bindingKind === 'entity')
&& validText(device.bindingRef)) {
liveBindings.add(`${device.bindingKind}:${device.bindingRef}`);
}
if (validBinding(device.marker?.binding)) liveBindings.add(device.marker.binding);
}
for (const [id, entry] of Object.entries(snapshot)) {
if (!liveIds.has(id) && !liveBindings.has(entry.binding)) decisions.push({
for (const id of options.cleanupSnapshotIds || []) {
if (snapshot[id]) decisions.push({
...unresolved(id, 'registry-unverified'), removeSnapshot: true,
});
}
+36 -17
View File
@@ -262,8 +262,9 @@ import {
import {
applyAreaRelocationResolution,
markerAreaSnapshotOf,
resolveAreaSnapshotCleanup,
resolveDeviceAreaRelocations,
type AreaRelocationResolution,
type AreaRelocationResolution, type MarkerAreaBinding,
} from './device-area-relocation';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
@@ -1012,6 +1013,8 @@ export class HouseplanCard extends LitElement {
private _newSyncKey = '';
/** Saved positions temporarily superseded by authoritative HA Area truth. */
private _areaRelocationIds = new Set<string>();
/** First authoritative absence is runtime evidence to re-check, never to delete. */
private _areaSnapshotCleanupCandidates = new Map<MarkerAreaBinding, number>();
private _areaRelocationSyncKey = '';
private _areaRelocationWrite: Promise<void> = Promise.resolve();
private _tip: {
@@ -4630,6 +4633,7 @@ export class HouseplanCard extends LitElement {
this._haRegistryConnection = connection;
this._haRegistryRev = -1;
this._haBindingCacheKey = '';
this._areaSnapshotCleanupCandidates.clear();
this._planHassMemo = null;
this._haRegistryRelease = acquireHaRegistries(this.hass, this._onHaRegistryUpdate);
this._onHaRegistryUpdate();
@@ -5091,14 +5095,7 @@ export class HouseplanCard extends LitElement {
}
let areaRelocations: AreaRelocationResolution | null = null;
if (registry.authoritative) {
areaRelocations = resolveDeviceAreaRelocations({
devices: this._devices,
model: this._model,
layout: this._layout,
snapshot: this._settings.marker_area_snapshot,
authoritative: true,
coordinateScale: NORM_W,
});
areaRelocations = this._resolveAreaRelocations(registry);
this._areaRelocationIds = new Set(areaRelocations.relocateIds);
if (this._areaRelocationIds.size) {
this._cancelDeviceDrag();
@@ -5155,6 +5152,35 @@ export class HouseplanCard extends LitElement {
}
}
/**
* Join filtered placement decisions with fail-safe registry lifecycle
* evidence. The two inputs deliberately stay separate: a device that cannot
* be painted is not thereby absent from Home Assistant.
*/
private _resolveAreaRelocations(registry = this._haRegistry): AreaRelocationResolution {
const cleanup = resolveAreaSnapshotCleanup({
snapshot: this._settings.marker_area_snapshot,
authoritative: registry.authoritative,
revision: registry.revision,
registryDevices: registry.devices,
registryEntities: registry.entities,
liveStates: this.hass?.states,
markers: this._markers,
previousCandidates: this._areaSnapshotCleanupCandidates,
});
this._areaSnapshotCleanupCandidates = cleanup.candidates;
if (cleanup.needsConfirmationRefresh && this._canEdit) refreshHaRegistries(this.hass);
return resolveDeviceAreaRelocations({
devices: this._devices,
model: this._model,
layout: this._layout,
snapshot: this._settings.marker_area_snapshot,
authoritative: registry.authoritative,
cleanupSnapshotIds: cleanup.removeIds,
coordinateScale: NORM_W,
});
}
/**
* "New device" flag (server-side, shared by every client): an auto device
* that appears after the known baseline was recorded gets a red dot until
@@ -5203,14 +5229,7 @@ export class HouseplanCard extends LitElement {
if (!this._serverCfg || !this._haRegistry.authoritative) return;
// The queue may have waited behind another registry/config mutation.
// Re-resolve at execution time so an explicit room choice or rebind wins.
const current = resolveDeviceAreaRelocations({
devices: this._devices,
model: this._model,
layout: this._layout,
snapshot: this._settings.marker_area_snapshot,
authoritative: true,
coordinateScale: NORM_W,
});
const current = this._resolveAreaRelocations();
this._areaRelocationIds = new Set(current.relocateIds);
const committed = new Set<string>();
const deletedPlacements = new Map<string, DevicePlacement>();