Files
houseplan-card/src/radar-editor.ts
T
Codex 03983946c5 Источники радара сравниваются по смыслу, а не по тексту
Гард `keepTwoPoint` решал, сохранять ли двухточечную калибровку, сравнением
`JSON.stringify`, то есть текста. Порядок ключей `sources` при этом меняет сам
билдер: `slots`/`ranges`/`zones`/`occupancy_entity`/`count_entity` он удаляет и
дописывает заново, а `availability_entity` остаётся на месте и уезжает в начало.
Конфиг, только что записанный этим же билдером, при следующем открытии
сравнивался неравным — и радар с `availability_entity` терял `refs` и `rms_cm`,
получая `method: manual`, при первом же обычном сохранении.

Сравнение стало каноничным по порядку ключей объектов и осталось чувствительным
к порядку элементов массивов: позиция слота — это его `target_N`. Проекция не
затронута — она считается от `mount`, `cell_cm` и `mirror`.

Issue: #567
User-Visible: no
2026-09-13 23:23:30 +03:00

376 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Pure Device-editor draft model for #485. */
import type {
DevItem, MarkerRadar, RadarLengthUnit, RadarProfile, SpaceModel,
} from './types';
import { isMarkerRadarV1 } from './radar-model';
export interface RadarEditorDraft {
original: MarkerRadar | null;
enabled: boolean;
showLive: boolean;
profile: RadarProfile;
roomId: string;
installationId: string;
mountX: string;
mountY: string;
heading: string;
rangeCm: string;
fovDeg: string;
mirror: boolean;
unit: RadarLengthUnit;
xEntities: string[];
yEntities: string[];
distanceEntities: string[];
angleEntities: string[];
rangeEntities: string[];
slotPresenceEntities: string[];
rangePresenceEntities: string[];
swapXY: boolean[];
xSigns: (1 | -1)[];
ySigns: (1 | -1)[];
angleUnit: 'degrees' | 'radians';
angleZero: 'forward' | 'right';
angleClockwise: boolean;
occupancyEntity: string;
countEntity: string;
availabilityEntity: string;
zoneEntity: string;
zoneKind: 'occupancy' | 'count';
/** Session-only result of Configure on plan; persisted only by marker Save. */
calibrationOverride?: MarkerRadar['calibration'];
inspectBusy?: boolean;
inspectError?: string;
inspection?: {
frame?: { health?: string; targets?: unknown[]; ranges?: unknown[]; complete?: boolean };
sources?: { entity_id?: string; state?: string | null; reported_at?: number }[];
};
}
export interface RadarRecognition {
eligible: boolean;
profile: RadarProfile;
reason: 'saved' | 'saved_unsupported' | 'ld2450' | 'radar_metadata' | 'none';
}
const LD2450_MODELS = new Set(['ld2450', 'hlkld2450', 'hilinkld2450']);
const canonicalModel = (value: unknown): string => String(value || '')
.toLowerCase().replace(/[^a-z0-9]/g, '');
export const freshRadarInstallationId = (): string => (
globalThis.crypto?.randomUUID?.()
|| `radar_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
);
export const radarAfterBindingChange = (
radar: RadarEditorDraft | null, touched: boolean, remove: boolean,
) => {
const saved = !!radar?.original;
return {
radar: saved ? radar : null,
radarEligible: saved,
radarTouched: saved ? touched : touched || !!radar,
radarRemove: saved ? remove : remove || !!radar,
};
};
interface RadarRegistryEntity { device_id?: string }
interface RadarRegistryDevice {
model?: string;
manufacturer?: string;
name?: string;
name_by_user?: string;
}
export interface RadarHassLike {
states?: Record<string, { state?: string; attributes?: Record<string, unknown> }>;
entities?: Record<string, RadarRegistryEntity>;
devices?: Record<string, RadarRegistryDevice>;
}
const finiteText = (value: unknown, fallback = ''): string => (
typeof value === 'number' && Number.isFinite(value) ? String(value) : fallback
);
const deviceEntityIds = (device: DevItem): string[] => Array.from(new Set([
...(device.allEntities || device.entities || []),
...(device.bindingKind === 'entity' && device.bindingRef ? [device.bindingRef] : []),
])).filter((entityId) => /^[a-z0-9_]+\.[a-z0-9_]+$/.test(entityId));
const suffixScore = (entityId: string, axis: 'x' | 'y', slot: number): boolean => {
const objectId = entityId.split('.', 2)[1] || '';
return new RegExp(`(?:^|_)target_?${slot}_${axis}(?:_|$)`).test(objectId)
|| new RegExp(`(?:^|_)target_${axis}_${slot}(?:_|$)`).test(objectId);
};
export function recognizeRadar(
device: DevItem,
registryHass: RadarHassLike,
): RadarRecognition {
if (device.marker?.radar && typeof device.marker.radar === 'object') {
if (isMarkerRadarV1(device.marker.radar)) {
return { eligible: true, profile: device.marker.radar.profile, reason: 'saved' };
}
return { eligible: true, profile: 'presence_v1', reason: 'saved_unsupported' };
}
const registryEntity = device.bindingKind === 'entity'
? registryHass?.entities?.[device.bindingRef || ''] : null;
const deviceId = device.bindingKind === 'device'
? device.bindingRef : registryEntity?.device_id;
const registryDevice = deviceId ? registryHass?.devices?.[deviceId] : null;
const entities = deviceEntityIds(device);
if (LD2450_MODELS.has(canonicalModel(registryDevice?.model || device.model))
&& entities.some((entityId) => suffixScore(entityId, 'x', 1))
&& entities.some((entityId) => suffixScore(entityId, 'y', 1))) {
return { eligible: true, profile: 'esphome_ld2450_v1', reason: 'ld2450' };
}
return { eligible: false, profile: 'presence_v1', reason: 'none' };
}
function pickEntity(entities: readonly string[], tests: RegExp[]): string {
return entities.find((entityId) => tests.some((test) => test.test(entityId))) || '';
}
function defaultSources(device: DevItem, profile: RadarProfile, hass: RadarHassLike) {
const entities = deviceEntityIds(device).filter((entityId) => !!hass?.states?.[entityId]);
const xEntities: string[] = [];
const yEntities: string[] = [];
for (let slot = 1; slot <= 3; slot++) {
xEntities.push(entities.find((entityId) => suffixScore(entityId, 'x', slot)) || '');
yEntities.push(entities.find((entityId) => suffixScore(entityId, 'y', slot)) || '');
}
const binary = entities.filter((entityId) => entityId.startsWith('binary_sensor.'));
const sensors = entities.filter((entityId) => entityId.startsWith('sensor.'));
const occupancyEntity = pickEntity(binary, [/(?:occupancy|presence|target)(?:_|$)/]);
return {
xEntities, yEntities,
distanceEntity: pickEntity(sensors, [/(?:target_)?distance(?:_|$)/, /distance/]),
angleEntity: pickEntity(sensors, [/(?:target_)?angle(?:_|$)/, /angle/]),
occupancyEntity,
countEntity: pickEntity(sensors, [/(?:target|people|presence)_count(?:_|$)/, /target_count/]),
zoneEntity: profile === 'zones_v1'
? pickEntity(binary, [/(?:zone|region).*(?:occupancy|presence)/, /zone/]) : '',
};
}
export function radarDraft(
device: DevItem,
space: SpaceModel,
markerPoint: readonly [number, number],
registryHass: RadarHassLike,
forceManual = false,
): RadarEditorDraft | null {
const recognition = recognizeRadar(device, registryHass);
const original = isMarkerRadarV1(device.marker?.radar) ? device.marker.radar : null;
if (!original && !recognition.eligible && !forceManual) return null;
const profile = original?.profile || recognition.profile;
const defaults = forceManual && !recognition.eligible || recognition.reason !== 'ld2450'
? {
xEntities: ['', '', ''], yEntities: ['', '', ''], distanceEntity: '', angleEntity: '',
occupancyEntity: '', countEntity: '', zoneEntity: '',
}
: defaultSources(device, profile, registryHass);
const sources = original?.sources || {};
const slots = sources.slots || [];
const cartesian = slots.filter((slot): slot is import('./types').RadarCartesianSlot =>
'x_entity' in slot);
const polar = slots.filter((slot): slot is import('./types').RadarPolarSlot =>
'distance_entity' in slot);
const ranges = sources.ranges || [];
const zone = sources.zones?.[0];
const targetCount = profile === 'esphome_ld2450_v1' ? 3
: profile === 'cartesian_v1' ? Math.max(1, cartesian.length)
: profile === 'polar_v1' ? Math.max(1, polar.length) : 1;
const roomId = original?.room_id || device.marker?.room_id
|| space.rooms.find((room) => room.area && room.area === device.area)?.id || '';
return {
original,
// Recognition only reveals the setup entry. A new radar remains off until
// the administrator explicitly enables it and completes the ordinary Save.
enabled: original?.enabled === true,
showLive: original?.show_live !== false,
profile,
roomId,
installationId: original?.mount?.installation_id || freshRadarInstallationId(),
// The editor canvas is 0..1000; persisted plan coordinates remain the
// canonical 0..1 space used by rooms and wall geometry.
mountX: finiteText(original?.mount?.x == null ? undefined : original.mount.x * 1000,
finiteText(markerPoint[0], '0')),
mountY: finiteText(original?.mount?.y == null ? undefined : original.mount.y * 1000,
finiteText(markerPoint[1], '0')),
heading: finiteText(original?.mount?.heading_deg, '0'),
rangeCm: finiteText(original?.mount?.range_cm, profile === 'esphome_ld2450_v1' ? '600' : ''),
fovDeg: finiteText(original?.mount?.fov_deg, profile === 'esphome_ld2450_v1' ? '120' : ''),
mirror: original?.calibration?.mirror === true,
unit: (cartesian[0]?.unit || polar[0]?.unit || ranges[0]?.unit || (profile === 'esphome_ld2450_v1' ? 'mm' : 'cm')) as RadarLengthUnit,
xEntities: Array.from({ length: targetCount },
(_, index) => cartesian[index]?.x_entity || defaults.xEntities[index] || ''),
yEntities: Array.from({ length: targetCount },
(_, index) => cartesian[index]?.y_entity || defaults.yEntities[index] || ''),
distanceEntities: Array.from({ length: targetCount }, (_, index) =>
polar[index]?.distance_entity || (index === 0 ? defaults.distanceEntity : '')),
angleEntities: Array.from({ length: targetCount }, (_, index) =>
polar[index]?.angle_entity || (index === 0 ? defaults.angleEntity : '')),
rangeEntities: ranges.length ? ranges.map((source) => source.entity_id)
: [defaults.distanceEntity],
slotPresenceEntities: Array.from({ length: targetCount }, (_, index) =>
(cartesian[index] || polar[index])?.presence_entity || ''),
rangePresenceEntities: (ranges.length ? ranges : [{}]).map((source) =>
'presence_entity' in source ? source.presence_entity || '' : ''),
swapXY: Array.from({ length: targetCount }, (_, index) => cartesian[index]?.swap_xy === true),
xSigns: Array.from({ length: targetCount }, (_, index) => cartesian[index]?.x_sign === -1 ? -1 : 1),
ySigns: Array.from({ length: targetCount }, (_, index) => cartesian[index]?.y_sign === -1 ? -1 : 1),
angleUnit: polar[0]?.angle_unit || 'degrees',
angleZero: polar[0]?.angle_zero || 'forward',
angleClockwise: polar[0]?.angle_clockwise !== false,
occupancyEntity: sources.occupancy_entity || defaults.occupancyEntity,
countEntity: sources.count_entity || defaults.countEntity,
availabilityEntity: sources.availability_entity || '',
zoneEntity: zone?.entity_id || defaults.zoneEntity,
zoneKind: zone?.kind || 'occupancy',
};
}
/**
* Источники, сравнимые по смыслу, а не по тексту (#567).
*
* Порядок ключей объекта смыслом не является, и опираться на него нельзя именно
* здесь: билдер ниже удаляет `slots`/`ranges`/`zones`/`occupancy_entity`/
* `count_entity` и дописывает их заново, а `availability_entity` остаётся на
* своём месте — значит конфиг, только что записанный этим же билдером,
* разворачивается в другом порядке. Прежнее `JSON.stringify` на этом ложно
* сообщало «источники изменились», и радар с `availability_entity` молча терял
* `refs` и `rms_cm` при первом же обычном сохранении.
*
* Порядок ЭЛЕМЕНТОВ массивов, наоборот, смысл: позиция слота — это его
* `target_N`, поэтому массивы не сортируются.
*
* Сравнение кодовых точек, а не `localeCompare`: порядок должен быть одинаковым
* в любой локали и любом движке, иначе сравнение начнёт зависеть от браузера.
*/
const canonicalSources = (value: unknown): string => JSON.stringify(value, (_key, item) => (
item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.entries(item as Record<string, unknown>)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
: item));
const numeric = (value: string): number | null => {
if (!value.trim()) return null;
const parsed = Number(value.replace(',', '.'));
return Number.isFinite(parsed) ? parsed : null;
};
export function radarConfigFromDraft(draft: RadarEditorDraft, cellCm: number): MarkerRadar | null {
const mountX = numeric(draft.mountX);
const mountY = numeric(draft.mountY);
const heading = numeric(draft.heading);
if (!draft.roomId || mountX == null || mountY == null || heading == null) return null;
const sources: MarkerRadar['sources'] = { ...(draft.original?.sources || {}) };
delete sources.slots;
delete sources.ranges;
delete sources.zones;
delete sources.occupancy_entity;
delete sources.count_entity;
if (draft.profile === 'esphome_ld2450_v1' || draft.profile === 'cartesian_v1') {
sources.slots = draft.xEntities.flatMap((xEntity, index) => {
const yEntity = draft.yEntities[index] || '';
if (!xEntity || !yEntity) return [];
return [{ id: `target_${index + 1}`, x_entity: xEntity, y_entity: yEntity,
unit: draft.profile === 'esphome_ld2450_v1' ? 'mm' : draft.unit,
...(draft.profile === 'cartesian_v1' && draft.swapXY[index] ? { swap_xy: true } : {}),
...(draft.profile === 'cartesian_v1' && draft.xSigns[index] === -1 ? { x_sign: -1 as const } : {}),
...(draft.profile === 'cartesian_v1' && draft.ySigns[index] === -1 ? { y_sign: -1 as const } : {}),
...(draft.slotPresenceEntities[index]
? { presence_entity: draft.slotPresenceEntities[index] } : {}) }];
});
if (!sources.slots.length) return null;
} else if (draft.profile === 'polar_v1') {
sources.slots = draft.distanceEntities.flatMap((distanceEntity, index) => {
const angleEntity = draft.angleEntities[index] || '';
if (!distanceEntity || !angleEntity) return [];
return [{ id: `target_${index + 1}`, distance_entity: distanceEntity,
angle_entity: angleEntity, unit: draft.unit, angle_unit: draft.angleUnit,
angle_zero: draft.angleZero, angle_clockwise: draft.angleClockwise,
...(draft.slotPresenceEntities[index]
? { presence_entity: draft.slotPresenceEntities[index] } : {}) }];
});
if (!sources.slots.length) return null;
} else if (draft.profile === 'range_v1') {
sources.ranges = draft.rangeEntities.flatMap((entityId, index) => entityId
? [{ id: `range_${index + 1}`, entity_id: entityId, unit: draft.unit,
...(draft.rangePresenceEntities[index]
? { presence_entity: draft.rangePresenceEntities[index] } : {}) }]
: []);
if (!sources.ranges.length) return null;
} else if (draft.profile === 'zones_v1') {
if (!draft.zoneEntity) return null;
sources.zones = [{ id: 'zone_1', kind: draft.zoneKind, entity_id: draft.zoneEntity }];
} else if (draft.profile === 'presence_v1' && !draft.occupancyEntity) return null;
if (draft.occupancyEntity) sources.occupancy_entity = draft.occupancyEntity;
if (draft.countEntity) sources.count_entity = draft.countEntity;
if (draft.availabilityEntity) sources.availability_entity = draft.availabilityEntity;
const rangeCm = numeric(draft.rangeCm);
const fovDeg = numeric(draft.fovDeg);
const normalizedHeading = ((heading % 360) + 360) % 360;
const mount: MarkerRadar['mount'] = {
...(draft.original?.mount || {}),
installation_id: draft.installationId,
x: mountX / 1000, y: mountY / 1000, heading_deg: normalizedHeading,
};
if (rangeCm != null && rangeCm > 0) mount.range_cm = rangeCm;
else delete mount.range_cm;
if (fovDeg != null && fovDeg > 0) mount.fov_deg = fovDeg;
else delete mount.fov_deg;
const original = draft.original;
const keepTwoPoint = original?.calibration?.method === 'two_point'
&& original.profile === draft.profile
&& canonicalSources(original.sources) === canonicalSources(sources)
&& original.mount.installation_id === draft.installationId
&& Math.abs(original.mount.x - mount.x) < 1e-12
&& Math.abs(original.mount.y - mount.y) < 1e-12
&& Math.abs(original.mount.heading_deg - normalizedHeading) < 1e-9
&& original.calibration.mirror === draft.mirror
&& Math.abs(original.calibration.cell_cm - cellCm) < 1e-9;
const calibration: MarkerRadar['calibration'] = draft.calibrationOverride
? { ...draft.calibrationOverride }
: keepTwoPoint
? { ...original.calibration }
: {
...(original?.calibration || {}),
method: draft.profile === 'zones_v1' || draft.profile === 'presence_v1'
? 'not_required' : 'manual',
mirror: draft.mirror,
cell_cm: cellCm,
};
if (!draft.calibrationOverride && !keepTwoPoint) {
delete calibration.refs;
delete calibration.rms_cm;
}
const result: MarkerRadar = {
...(draft.original || {}),
version: 1,
enabled: draft.enabled,
profile: draft.profile,
sources,
mount,
room_id: draft.roomId,
calibration,
};
if (draft.showLive) delete result.show_live;
else result.show_live = false;
return result;
}
export function radarSourceCandidates(
_device: DevItem,
hass: RadarHassLike,
): { numeric: string[]; binary: string[] } {
// hass.states is already permission-filtered for the current user. Registry
// entities may still include disabled or unreadable diagnostics and must not
// leak into a manual source selector.
const entities = Object.keys(hass?.states || {});
return {
numeric: entities.filter((entityId) => entityId.startsWith('sensor.')).sort(),
binary: entities.filter((entityId) => entityId.startsWith('binary_sensor.')).sort(),
};
}