Files
houseplan-card/src/radar-editor.ts
T
2026-09-09 03:52:23 +03:00

353 lines
16 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',
};
}
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
&& JSON.stringify(original.sources) === JSON.stringify(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(),
};
}