mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
fix: follow Home Assistant Area marker moves
Issue: #126 User-Visible: yes
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user