Files
houseplan-card/src/device-area-relocation.ts
T
2026-09-02 19:18:28 +03:00

347 lines
13 KiB
TypeScript

import { pointStrictlyInside } from './logic';
import type {
DevItem, Marker, MarkerAreaBinding, MarkerAreaSnapshot,
RoomCfg, SpaceModel,
} from './types';
export type { MarkerAreaBinding, MarkerAreaSnapshot } from './types';
export const MARKER_AREA_SNAPSHOT_LIMIT = 20_000;
export interface AreaRelocationLayoutEntry {
s?: string;
x: number;
y: number;
}
export type AreaRelocationLayout = Record<string, AreaRelocationLayoutEntry | undefined>;
export type AreaRelocationReason =
| 'unchanged'
| 'new-without-layout'
| 'backfill-same-room'
| 'backfill-stale-room'
| 'backfill-cross-space'
| 'backfill-ambiguous'
| 'area-changed'
| 'target-unresolved'
| 'registry-unverified';
export interface AreaRelocationDecision {
id: string;
binding: MarkerAreaBinding | null;
area: string | null;
relocate: boolean;
updateSnapshot: boolean;
removeSnapshot: boolean;
reason: AreaRelocationReason;
}
export interface AreaRelocationResolution {
decisions: AreaRelocationDecision[];
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;
}
const validText = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= 500;
function validBinding(value: unknown): value is MarkerAreaBinding {
return validText(value) && (value.startsWith('device:') || value.startsWith('entity:'))
&& value.indexOf(':') < value.length - 1;
}
/** Defensive read boundary for old/foreign config that bypassed backend validation. */
export function markerAreaSnapshotOf(value: unknown): MarkerAreaSnapshot {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const result: MarkerAreaSnapshot = {};
for (const [id, raw] of Object.entries(value as Record<string, unknown>)
.slice(-MARKER_AREA_SNAPSHOT_LIMIT)) {
if (!validText(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
const binding = (raw as { binding?: unknown }).binding;
const area = (raw as { area?: unknown }).area;
if (validBinding(binding) && validText(area)) result[id] = { binding, area };
}
return result;
}
/** Remove lifecycle metadata together with markers that no longer exist. */
export function removeMarkerAreaSnapshots(
value: unknown, markerIds: Iterable<string>,
): MarkerAreaSnapshot {
const next = markerAreaSnapshotOf(value);
for (const markerId of markerIds) delete next[markerId];
return next;
}
function explicitPlacement(marker: Marker | undefined): boolean {
if (!marker) return false;
if (typeof marker.area === 'string' && marker.area.length > 0) return true;
return marker.area === null
&& typeof marker.space === 'string' && marker.space.length > 0
&& typeof marker.room_id === 'string' && marker.room_id.length > 0;
}
/** Exact registry binding eligible for automatic Area following. */
export function registryFollowingBinding(device: DevItem): MarkerAreaBinding | null {
if (device.virtual || device.bindingStatus?.kind === 'unverified') return null;
if ((device.bindingKind !== 'device' && device.bindingKind !== 'entity')
|| !validText(device.bindingRef)) return null;
const binding = `${device.bindingKind}:${device.bindingRef}` as MarkerAreaBinding;
const marker = device.marker;
if (marker?.removed || explicitPlacement(marker)) return null;
if (marker && marker.binding !== binding) return null;
// Markerless entity items are automatic/composite light groups. Direct
// standalone entities always have a persisted marker with the exact binding.
if (device.bindingKind === 'entity' && !marker) return null;
return binding;
}
function roomPolygon(room: RoomCfg): number[][] | null {
if (Array.isArray(room.poly) && room.poly.length >= 3) return room.poly;
const { x, y, w, h } = room;
if (![x, y, w, h].every(Number.isFinite) || Number(w) <= 0 || Number(h) <= 0) return null;
return [[x!, y!], [x! + w!, y!], [x! + w!, y! + h!], [x!, y! + h!]];
}
function containingRooms(
space: SpaceModel, point: number[],
): RoomCfg[] {
return space.rooms.filter((room) => {
const polygon = roomPolygon(room);
return !!polygon && pointStrictlyInside(point, polygon);
});
}
function unresolved(id: string, reason: AreaRelocationReason): AreaRelocationDecision {
return {
id, binding: null, area: null, relocate: false,
updateSnapshot: false, removeSnapshot: false, reason,
};
}
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
* interactive and Hosted Static placement projections.
*/
export function resolveDeviceAreaRelocations(
options: ResolveAreaRelocationsOptions,
): AreaRelocationResolution {
const decisions: AreaRelocationDecision[] = [];
const relocateIds = new Set<string>();
if (!options.authoritative) return { decisions, relocateIds };
const snapshot = markerAreaSnapshotOf(options.snapshot);
for (const id of options.cleanupSnapshotIds || []) {
if (snapshot[id]) decisions.push({
...unresolved(id, 'registry-unverified'), removeSnapshot: true,
});
}
const scale = Number.isFinite(options.coordinateScale) && Number(options.coordinateScale) > 0
? Number(options.coordinateScale) : 1000;
const targets = new Map<string, Array<{ space: SpaceModel; room: RoomCfg }>>();
for (const space of options.model) for (const room of space.rooms) {
if (!room.area) continue;
const entries = targets.get(room.area) || [];
entries.push({ space, room });
targets.set(room.area, entries);
}
for (const device of options.devices) {
const previous = snapshot[device.id];
const binding = registryFollowingBinding(device);
if (!binding) {
if (previous) decisions.push({
...unresolved(device.id, 'registry-unverified'), removeSnapshot: true,
});
continue;
}
const area = validText(device.area) ? device.area : null;
if (!area) {
decisions.push({
id: device.id, binding, area: null, relocate: false,
updateSnapshot: false, removeSnapshot: false, reason: 'registry-unverified',
});
continue;
}
const target = targets.get(area);
if (!target || target.length !== 1) {
decisions.push({
id: device.id, binding, area, relocate: false,
updateSnapshot: false, removeSnapshot: false, reason: 'target-unresolved',
});
continue;
}
if (previous?.binding === binding) {
const relocate = previous.area !== area;
if (relocate) relocateIds.add(device.id);
decisions.push({
id: device.id, binding, area, relocate, updateSnapshot: relocate,
removeSnapshot: false, reason: relocate ? 'area-changed' : 'unchanged',
});
continue;
}
const saved = options.layout[device.id];
if (!saved) {
decisions.push({
id: device.id, binding, area, relocate: false, updateSnapshot: true,
removeSnapshot: false, reason: 'new-without-layout',
});
continue;
}
const [{ space: targetSpace, room: targetRoom }] = target;
if (saved.s && saved.s !== targetSpace.id) {
relocateIds.add(device.id);
decisions.push({
id: device.id, binding, area, relocate: true, updateSnapshot: true,
removeSnapshot: false, reason: 'backfill-cross-space',
});
continue;
}
if (saved.s !== targetSpace.id || !Number.isFinite(saved.x) || !Number.isFinite(saved.y)) {
decisions.push({
id: device.id, binding, area, relocate: false, updateSnapshot: true,
removeSnapshot: false, reason: 'backfill-ambiguous',
});
continue;
}
const rooms = containingRooms(targetSpace, [saved.x * scale, saved.y * scale]);
const source = rooms.length === 1 ? rooms[0] : null;
const sameRoom = source === targetRoom
|| (!!source?.id && !!targetRoom.id && source.id === targetRoom.id);
const staleRoom = !!source?.area && source.area !== area;
const relocate = !sameRoom && staleRoom;
if (relocate) relocateIds.add(device.id);
decisions.push({
id: device.id, binding, area, relocate, updateSnapshot: true,
removeSnapshot: false,
reason: relocate ? 'backfill-stale-room'
: sameRoom ? 'backfill-same-room' : 'backfill-ambiguous',
});
}
return { decisions, relocateIds };
}
/** Apply baseline/cleanup decisions and only the relocations whose delete committed. */
export function applyAreaRelocationResolution(
value: unknown,
resolution: AreaRelocationResolution,
committedRelocations: ReadonlySet<string> = new Set(),
): MarkerAreaSnapshot {
const next = markerAreaSnapshotOf(value);
for (const decision of resolution.decisions) {
if (decision.removeSnapshot) {
delete next[decision.id];
continue;
}
if (!decision.updateSnapshot || !decision.binding || !decision.area) continue;
if (decision.relocate && !committedRelocations.has(decision.id)) continue;
next[decision.id] = { binding: decision.binding, area: decision.area };
}
return next;
}