fix: follow Home Assistant Area marker moves

Issue: #126
User-Visible: yes
This commit is contained in:
Matysh
2026-08-31 09:45:53 +03:00
parent c8ef4f19e4
commit 633cb20e59
45 changed files with 1622 additions and 589 deletions
+249
View File
@@ -0,0 +1,249 @@
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 ResolveAreaRelocationsOptions {
devices: readonly DevItem[];
model: readonly SpaceModel[];
layout: AreaRelocationLayout;
snapshot: unknown;
authoritative: boolean;
/** 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(0, 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,
};
}
/**
* 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);
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;
}
+150 -3
View File
@@ -256,6 +256,12 @@ import {
type DevicePlacement,
type DevicePositionState,
} from './device-position-history';
import {
applyAreaRelocationResolution,
markerAreaSnapshotOf,
resolveDeviceAreaRelocations,
type AreaRelocationResolution,
} from './device-area-relocation';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
buildGlowClipGeometry, buildLightBarrierScene, createGlowRuntimeState,
@@ -1001,6 +1007,10 @@ export class HouseplanCard extends LitElement {
private _regSignature = '';
private _defPos: Record<string, { x: number; y: number }> = {};
private _newSyncKey = '';
/** Saved positions temporarily superseded by authoritative HA Area truth. */
private _areaRelocationIds = new Set<string>();
private _areaRelocationSyncKey = '';
private _areaRelocationWrite: Promise<void> = Promise.resolve();
private _tip: {
x: number;
y: number;
@@ -2275,6 +2285,7 @@ export class HouseplanCard extends LitElement {
description: string;
pdfs: PdfRef[];
room: string;
roomTouched: boolean;
hideFromPlan: boolean; // 'space#area' for a virtual one
busy: boolean;
} | null = null;
@@ -5031,9 +5042,36 @@ export class HouseplanCard extends LitElement {
cacheHaBindingStatuses(statuses);
this._haBindingCacheKey = bindingCacheKey;
}
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,
});
this._areaRelocationIds = new Set(areaRelocations.relocateIds);
if (this._areaRelocationIds.size) {
this._cancelDeviceDrag();
this._devicePositionHistory.clear();
const dialog = this._markerDialog;
if (dialog?.devId && !dialog.roomTouched && this._areaRelocationIds.has(dialog.devId)) {
const current = this._devices.find((device) => device.id === dialog.devId);
if (current) this._markerDialog = {
...dialog,
room: current.marker?.room_id
? `${current.space}#@${current.marker.room_id}`
: current.space && current.area ? `${current.space}#${current.area}` : '',
};
}
}
}
this._defPos = this._defaultPositions();
this._syncNewDevices();
this._seedHiddenDevices();
if (areaRelocations) this._syncAreaRelocations(areaRelocations);
// Rebuilds also happen without a hass update (marker save/rebind). Establish
// new baselines and clear old flashes synchronously, before the next paint.
this._syncActivityRuntime();
@@ -5094,10 +5132,119 @@ export class HouseplanCard extends LitElement {
}
}
/**
* Persist authoritative registry-Area transitions without ever letting a
* stale saved point win between registry paint and server acknowledgement.
* Layout deletion is deliberately completed before provenance advances.
*/
private _syncAreaRelocations(resolution: AreaRelocationResolution): void {
if (!this._serverCfg || !this._norm || !this._canEdit || !this._haRegistry.authoritative) return;
const actionable = resolution.decisions.filter(
(decision) => decision.updateSnapshot || decision.removeSnapshot,
);
if (!actionable.length) return;
const key = contentFingerprint({
snapshot: markerAreaSnapshotOf(this._settings.marker_area_snapshot),
decisions: actionable,
});
if (key === this._areaRelocationSyncKey) return;
this._areaRelocationSyncKey = key;
this._areaRelocationWrite = this._areaRelocationWrite.catch(() => undefined).then(async () => {
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,
});
this._areaRelocationIds = new Set(current.relocateIds);
const committed = new Set<string>();
let deleteFailed = false;
for (const decision of current.decisions) {
if (!decision.relocate) continue;
const before = devicePlacement(this._layout, decision.id);
try {
await this._persistDevicePlacement(decision.id, null);
committed.add(decision.id);
this._areaRelocationIds.delete(decision.id);
} catch (error: unknown) {
deleteFailed = true;
this._layout = applyDevicePlacement(this._layout, decision.id, before);
this._showToast(this._t('toast.pos_save_failed', { err: this._errText(error) }));
}
}
const st = this._settings;
const nextSnapshot = applyAreaRelocationResolution(
st.marker_area_snapshot, current, committed,
);
const nextAttention = [...new Set([
...(Array.isArray(st.new_device_ids) ? st.new_device_ids : []),
...committed,
])];
const snapshotChanged = contentFingerprint(nextSnapshot)
!== contentFingerprint(markerAreaSnapshotOf(st.marker_area_snapshot));
const attentionChanged = contentFingerprint(nextAttention)
!== contentFingerprint(Array.isArray(st.new_device_ids) ? st.new_device_ids : []);
if (snapshotChanged || attentionChanged) {
const previousSnapshot = st.marker_area_snapshot;
const previousAttention = st.new_device_ids;
this._serverCfg = {
...this._serverCfg,
settings: {
...st,
marker_area_snapshot: nextSnapshot,
...(attentionChanged ? { new_device_ids: nextAttention } : {}),
},
};
this._cfgEpoch++;
try {
// Await this internal lifecycle write. A debounced fire-and-forget
// save would leave the local snapshot advanced after a rejection,
// suppressing the retry required by the delete-first contract.
await this._writeConfig();
} catch (error: unknown) {
const current = this._settings;
// Restore only if no newer mutation replaced our exact attempt while
// the serialized write was in flight.
if (contentFingerprint(current.marker_area_snapshot)
=== contentFingerprint(nextSnapshot)
&& contentFingerprint(current.new_device_ids)
=== contentFingerprint(attentionChanged ? nextAttention : previousAttention)) {
const restored = { ...current };
if (previousSnapshot === undefined) delete restored.marker_area_snapshot;
else restored.marker_area_snapshot = previousSnapshot;
if (previousAttention === undefined) delete restored.new_device_ids;
else restored.new_device_ids = previousAttention;
this._serverCfg = { ...this._serverCfg!, settings: restored };
this._cfgContentFingerprint = contentFingerprint(this._serverCfg);
}
this._areaRelocationSyncKey = '';
this._regSignature = '';
const code = error && typeof error === 'object' && 'code' in error
? (error as { code?: unknown }).code : undefined;
if (code === 'conflict') {
this._showToast(this._t('toast.conflict'));
void this._reloadConfigOnly(true);
} else {
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(error) }));
}
}
}
if (deleteFailed) this._areaRelocationSyncKey = '';
this.requestUpdate();
});
}
/** Ids currently flagged as new (drawn with the red dot). */
private get _newIds(): Set<string> {
const list = (this._settings as any).new_device_ids;
return new Set(Array.isArray(list) ? list : []);
const list = this._settings.new_device_ids;
return new Set([...(Array.isArray(list) ? list : []), ...this._areaRelocationIds]);
}
/** First visit to the device's editor acknowledges its "new" flag. */
@@ -5186,7 +5333,7 @@ export class HouseplanCard extends LitElement {
private _livePos(d: DevItem): { x: number; y: number } {
const s = this._spaceModelById(d.space);
const saved = this._layout[d.id];
const saved = this._areaRelocationIds.has(d.id) ? undefined : this._layout[d.id];
if (saved) {
if (this._norm) {
if (saved.s === d.space) {
+58 -13
View File
@@ -147,6 +147,7 @@ import {
type ToggleSkipReason,
} from './device-toggle';
import { toggleEntityWriteFields } from './marker-toggle-entity';
import { removeMarkerAreaSnapshots } from './device-area-relocation';
import {
adoptVirtualLightServerSnapshot,
applyVirtualLightEvent,
@@ -977,7 +978,7 @@ export interface HouseplanEditorHostPort {
_layout: DeviceLayout;
_layoutRev: number;
_logicalViewCenter: (projection: "flat" | "iso") => { x: number; y: number; } | null;
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; hideFromPlan: boolean; busy: boolean; } | null;
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; hideFromPlan: boolean; busy: boolean; } | null;
_markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null;
_markerPreviewMemo: { key: string; device: DevItem | null; } | null;
_markers: Marker[];
@@ -7921,6 +7922,7 @@ public _openMarkerDialog(d?: DevItem): void {
room: d.marker?.room_id
? d.space + '#@' + d.marker.room_id
: d.space && d.area ? d.space + '#' + d.area : '',
roomTouched: false,
hideFromPlan: d.marker?.hidden === true,
busy: false,
};
@@ -7947,7 +7949,8 @@ public _openMarkerDialog(d?: DevItem): void {
valueSource: null, valueSourceTouched: false,
originalHasValueSource: false, originalValueSource: undefined,
useClimateTemp: false, glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', hideFromPlan: false, busy: false,
link: '', description: '', pdfs: [], room: '', roomTouched: false,
hideFromPlan: false, busy: false,
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
};
}
@@ -8189,14 +8192,27 @@ public async _saveMarker(): Promise<void> {
const cfg = this.host._serverCfg;
if (!cfg) return;
const markers = cfg.markers || [];
const roomRef = parseRoomRef(dlg.room);
let space: string | null = roomRef?.space || null;
const area: string | null = roomRef?.area || null;
const roomId: string | null = roomRef?.roomId || null;
const id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36));
const oldId = dlg.devId;
const prevDev = oldId ? this.host._devices.find((x) => x.id === oldId) : null;
const explicitSpaceId = roomRef?.space || prevDev?.space || null;
const previousMarker = oldId ? markers.find((candidate) => candidate.id === oldId) : null;
const previousExplicit = !!previousMarker && (
(typeof previousMarker.area === 'string' && previousMarker.area.length > 0)
|| (previousMarker.area === null && !!previousMarker.space && !!previousMarker.room_id)
);
// Opening a registry-following marker shows its effective HA room in the
// select, but that display value is not an explicit override. Preserve
// absence until the user actually changes the selector; this also prevents
// a dialog opened during an HA Area transition from saving its stale draft.
const writePlacement = dlg.binding === 'virtual' || dlg.roomTouched || previousExplicit;
const roomRef = dlg.binding === 'virtual' || dlg.roomTouched ? parseRoomRef(dlg.room) : null;
let space: string | null = previousExplicit && !dlg.roomTouched
? previousMarker?.space || null : roomRef?.space || null;
const area: string | null = previousExplicit && !dlg.roomTouched
? previousMarker?.area || null : roomRef?.area || null;
const roomId: string | null = previousExplicit && !dlg.roomTouched
? previousMarker?.room_id || null : roomRef?.roomId || null;
const explicitSpaceId = space || prevDev?.space || null;
const targetSpaceModel = explicitSpaceId
? this.host._spaceModelById(explicitSpaceId)
: this.host._spaceModel();
@@ -8253,14 +8269,14 @@ public async _saveMarker(): Promise<void> {
hidden: dlg.hideFromPlan ? true : false,
};
// save the room choice (always for virtual ones; for bound ones — if chosen)
if (dlg.binding === 'virtual' || dlg.room) {
if (writePlacement) {
marker.space = space;
marker.area = area;
marker.room_id = roomId;
}
// the room changed → move the icon to its center
const prevRoomId = prevDev?.marker?.room_id ?? null;
const roomChanged = !!dlg.room && prevDev != null
const roomChanged = dlg.roomTouched && prevDev != null
&& (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId);
// Rebinding changes the marker id, so the uploaded files must follow.
// Order matters (review CR-2): COPY first, save the config, and only then
@@ -8301,6 +8317,17 @@ public async _saveMarker(): Promise<void> {
&& (marker.binding === 'virtual' || m.binding !== marker.binding),
);
cfg.markers.push(marker);
const obsoleteAreaSnapshotIds = new Set(replacedRemovedIds);
if (oldId && oldId !== id) obsoleteAreaSnapshotIds.add(oldId);
obsoleteAreaSnapshotIds.delete(id);
if (obsoleteAreaSnapshotIds.size && cfg.settings?.marker_area_snapshot) {
cfg.settings = {
...cfg.settings,
marker_area_snapshot: removeMarkerAreaSnapshots(
cfg.settings.marker_area_snapshot, obsoleteAreaSnapshotIds,
),
};
}
// Position rule (owner's decision, v1.33.4): editing an existing icon —
// rebinding it to another HA device/entity or to another room — must NOT
// move it. Its current position (saved or the ephemeral auto one) is
@@ -8413,8 +8440,16 @@ public async _deleteMarker(): Promise<void> {
const deletion = deletePlanMarkerRecords(
cfg.markers, targetId, binding, binding === 'virtual',
);
cfg.markers = removeMarkerControlReferences(deletion.markers, deletion.cleanupIds);
const cleanupIds = deletion.cleanupIds;
cfg.markers = removeMarkerControlReferences(deletion.markers, deletion.cleanupIds);
const cleanupIds = deletion.cleanupIds;
if (cleanupIds.size && cfg.settings?.marker_area_snapshot) {
cfg.settings = {
...cfg.settings,
marker_area_snapshot: removeMarkerAreaSnapshots(
cfg.settings.marker_area_snapshot, cleanupIds,
),
};
}
this.host._markerDialog = { ...currentDialog, busy: true };
try {
await this._saveConfigNow();
@@ -12286,10 +12321,18 @@ public _markerDraft(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>): M
hidden: d.hideFromPlan,
vacuum: previous?.vacuum || null,
};
if (d.binding === 'virtual' || d.room) {
const previousExplicit = !!previous && (
(typeof previous.area === 'string' && previous.area.length > 0)
|| (previous.area === null && !!previous.space && !!previous.room_id)
);
if (d.binding === 'virtual' || d.roomTouched) {
marker.space = roomRef?.space || (d.binding === 'virtual' ? this.host._space : null);
marker.area = roomRef?.area || null;
marker.room_id = roomRef?.roomId || null;
} else if (previousExplicit) {
marker.space = previous!.space;
marker.area = previous!.area;
marker.room_id = previous!.room_id;
}
return marker;
}
@@ -12807,7 +12850,9 @@ public _renderMarkerDialog(): TemplateResult {
<label for="marker-room">${this.host._t('marker.room_label')}${isVirtual ? '' : this.host._t('marker.room_override')}</label>
<select id="marker-room" class="areasel"
@change=${(e: Event) => (this.host._markerDialog = { ...d, room: (e.target as HTMLSelectElement).value })}>
@change=${(e: Event) => (this.host._markerDialog = {
...d, room: (e.target as HTMLSelectElement).value, roomTouched: true,
})}>
<option value="" ?selected=${!d.room}>
${isVirtual ? this.host._t('marker.room_choose') : this.host._t('marker.room_auto')}
</option>
+12
View File
@@ -8,6 +8,7 @@
import { LitElement, html, nothing, noChange, css, type TemplateResult, type PropertyValues } from 'lit';
import { cardStyles } from './styles';
import { buildSpaceDevices, renderSpaceStatic, spaceModels } from './space-render';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { resolveSpaceCardFit, type SpaceCardFit } from './space-geometry';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
@@ -92,6 +93,7 @@ class HouseplanSpaceCard extends LitElement {
private _haRegistryConnection: any = null;
private _haRegistryRevision = -1;
private _devices: DevItem[] = [];
private _areaRelocationIds = new Set<string>();
private _continuity = this._newContinuityController();
private _continuityHistory: import('./visual-continuity').ContinuityTraceEvent[] = [];
private _continuityUnsub?: () => void;
@@ -449,6 +451,15 @@ class HouseplanSpaceCard extends LitElement {
cfg: this._snap.config,
lang: this._lang,
});
if (registry.authoritative) {
this._areaRelocationIds = resolveDeviceAreaRelocations({
devices,
model: spaceModels(this._snap.config),
layout: this._snap.layout || {},
snapshot: this._snap.config.settings?.marker_area_snapshot,
authoritative: true,
}).relocateIds;
}
this._syncActivity(devices, activeRegistryHass(this.hass, registry), this.hass);
this._devices = devices;
}
@@ -845,6 +856,7 @@ class HouseplanSpaceCard extends LitElement {
}
: haRegistrySnapshot(this.hass),
devices: [...(deviceSnapshot?.devices || this._devices)],
areaRelocationIds: this._areaRelocationIds,
presentations: deviceSnapshot?.presentations,
activityRuntime: this._activityRuntime,
reducedMotion: this._reducedMotion,
+5 -2
View File
@@ -626,9 +626,12 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
}
/** Marker position in render units: saved layout → default grid → space centre. */
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
export function markerPos(
d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel,
ignoreSaved: ReadonlySet<string> = new Set(),
): Pt {
const saved = layout[d.id];
if (saved && saved.s === d.space) {
if (!ignoreSaved.has(d.id) && saved && saved.s === d.space) {
return { x: saved.x * NORM_W, y: saved.y * NORM_W };
}
if (defPos[d.id]) return defPos[d.id];
+18 -3
View File
@@ -56,6 +56,7 @@ import {
} from './space-geometry';
import { resolveZeroWalls } from './zero-walls';
import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import {
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
@@ -201,6 +202,8 @@ export interface StaticRenderOpts {
lang: Lang;
/** Optional roster prepared by the card so its activity runtime sees the same instances. */
devices?: DevItem[];
/** Registry relocations resolved by the card outside its render path. */
areaRelocationIds?: ReadonlySet<string>;
activityRuntime?: ReadonlyMap<string, PresentationActivityRuntime>;
presentations?: ReadonlyMap<string, ResolvedDevicePresentation>;
liveStates?: boolean;
@@ -277,6 +280,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const planHass = o.registry ? activeRegistryHass(o.hass, o.registry) : o.hass;
const registryHass = o.registry ? fullRegistryHass(o.hass, o.registry) : o.hass;
const all = o.devices || buildSpaceDevices(o);
const areaRelocationIds = o.areaRelocationIds || resolveDeviceAreaRelocations({
devices: all, model: models, layout: o.layout,
snapshot: o.cfg.settings?.marker_area_snapshot,
authoritative: o.registry?.authoritative === true, coordinateScale: NORM_W,
}).relocateIds;
// Two lists, two jobs (HP-1510-01): AGGREGATION sees every device of the
// space — hidden ones still count toward room LQI, same as the full card —
// while RENDERING sees only the visible ones (there is no editor here, so
@@ -556,7 +564,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
}
const planLightSources = resolvedLightSources(planHass, devs, null, o.virtualLights);
const markers = devs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
const p = markerPos(d, o.layout, o.cfg, defPos, space, areaRelocationIds);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
const showLqi = disp.showLqi ?? (o.showSignal !== false);
@@ -594,7 +602,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
data-binding-status=${d.bindingStatus?.kind === 'ha_disabled' ? 'ha-disabled' : d.bindingStatus?.kind || 'active'}
data-disabled-reason=${presentation.disabledReason ? presentation.disabledReason.replace('_', '-') : nothing}
style="${st.join(';')}">
${renderDeviceFace(presentation, { surface: 'static-card' })}
${renderDeviceFace(presentation, {
surface: 'static-card',
newDevice: areaRelocationIds.has(d.id)
|| !!o.cfg.settings?.new_device_ids?.includes(d.id),
newDeviceTitle: t(o.lang, 'device.new'),
})}
</div>`;
});
@@ -721,7 +734,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
defaultRadiusUnits: (defaultRadiusCm / cellCm) * GRID_PITCH,
cellCm,
gridPitch: GRID_PITCH,
position: (device) => markerPos(device, o.layout, o.cfg, defPos, space),
position: (device) => markerPos(
device, o.layout, o.cfg, defPos, space, areaRelocationIds,
),
});
const seen = new Set<string>();
const spots: GlowSpot[] = [];
+15 -2
View File
@@ -240,12 +240,25 @@ export interface ServerConfig {
exclude_integrations?: string[];
group_lights?: boolean;
show_all?: boolean; // legacy: removed when the config is materialised
/** The filter flags are materialised into markers (docs/FILTERING.md). */
filter_seeded?: boolean;
/** The filter flags are materialised into markers (docs/FILTERING.md). */
filter_seeded?: boolean;
known_devices?: string[];
new_device_ids?: string[];
/** Internal registry-Area provenance; not a user-facing setting. */
marker_area_snapshot?: MarkerAreaSnapshot;
icon_rules?: { pattern: string; icon: string }[];
};
}
export type MarkerAreaBinding = `device:${string}` | `entity:${string}`;
export interface MarkerAreaSnapshotEntry {
binding: MarkerAreaBinding;
area: string;
}
export type MarkerAreaSnapshot = Record<string, MarkerAreaSnapshotEntry>;
export interface DevItem {
id: string;
name: string;