mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
@@ -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
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user