mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
Гард `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
376 lines
17 KiB
TypeScript
376 lines
17 KiB
TypeScript
/** 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(),
|
||
};
|
||
}
|