fix: harden presence radar stage one

User-Visible: yes
Issue: #485
This commit is contained in:
Matysh
2026-09-09 03:52:23 +03:00
parent 63eb4f315c
commit d12488f40d
29 changed files with 552 additions and 231 deletions
+52 -27
View File
@@ -3,7 +3,8 @@ import { html, nothing, type TemplateResult } from 'lit';
import type { I18nKey } from '../i18n';
import {
radarConfigFromDraft, radarDraft, radarSourceCandidates, recognizeRadar,
freshRadarInstallationId, radarConfigFromDraft, radarDraft, radarSourceCandidates,
recognizeRadar,
type RadarEditorDraft, type RadarHassLike,
} from '../radar-editor';
import type { RadarSetupController } from '../radar-setup';
@@ -50,7 +51,10 @@ export interface RadarSectionOptions {
}
/** Rendered from the lazy editor chunk; ordinary View never loads this file. */
export function renderRadarSection(options: RadarSectionOptions): TemplateResult {
export function renderRadarSection(
options: RadarSectionOptions,
placement: 'main' | 'additional' = 'main',
): TemplateResult {
const { dialog: d, device } = options;
if (!device) return html``;
options.setup.syncMarker(device.marker?.id || device.id);
@@ -59,6 +63,27 @@ export function renderRadarSection(options: RadarSectionOptions): TemplateResult
const remove = () => options.updateDialog({
radar: null, radarTouched: true, radarRemove: true,
});
const begin = () => {
const space = options.spaceModelById(device.space) || options.currentSpace();
if (!space) return;
const point = options.position(device);
const radar = radarDraft(device, space, [point.x, point.y], options.registryHass, true);
if (!radar) return;
options.updateDialog({
radar, radarEligible: true, radarTouched: true, radarRemove: false,
});
};
const manualEntry = !savedUnsupported && d.bindingMode !== 'virtual'
&& (!d.radar || d.radarRemove) && !recognition.eligible;
if (placement === 'additional') {
if (!manualEntry) return html``;
return html`<details class="markerlightgroup radaradditional">
<summary>${options.t('radar.additional_actions')}</summary>
<button class="btn ghost" type="button" @click=${begin}>
<ha-icon icon="mdi:radar"></ha-icon>${options.t('radar.declare')}
</button>
</details>`;
}
if ((!d.radar || d.radarRemove) && options.setup.isActive()) options.setup.reset();
if (savedUnsupported || d.bindingMode === 'virtual' && !!device.marker?.radar) {
return html`<fieldset class="markerlightgroup radargroup">
@@ -72,16 +97,6 @@ export function renderRadarSection(options: RadarSectionOptions): TemplateResult
</fieldset>`;
}
if (d.bindingMode === 'virtual') return html``;
const begin = () => {
const space = options.spaceModelById(device.space) || options.currentSpace();
if (!space) return;
const point = options.position(device);
const radar = radarDraft(device, space, [point.x, point.y], options.registryHass, true);
if (!radar) return;
options.updateDialog({
radar, radarEligible: true, radarTouched: true, radarRemove: false,
});
};
if (!d.radar || d.radarRemove) {
if (recognition.eligible) {
return html`<fieldset class="markerlightgroup radargroup">
@@ -93,12 +108,7 @@ export function renderRadarSection(options: RadarSectionOptions): TemplateResult
</button>
</fieldset>`;
}
return html`<details class="markerlightgroup radaradditional">
<summary>${options.t('radar.additional_actions')}</summary>
<button class="btn ghost" type="button" @click=${begin}>
<ha-icon icon="mdi:radar"></ha-icon>${options.t('radar.declare')}
</button>
</details>`;
return html``;
}
const radar = d.radar;
@@ -172,17 +182,26 @@ export function renderRadarSection(options: RadarSectionOptions): TemplateResult
});
}
};
const configureOnPlan = () => {
const room = roomOptions.find((candidate) => candidate.id === radar.roomId);
if (!space || !room || !Array.isArray(room.poly) || room.poly.length < 3
|| !radarConfigFromDraft(radar, space.cellCm || 5)) {
const configureOnPlan = (draft = radar) => {
const room = roomOptions.find((candidate) => candidate.id === draft.roomId);
if (!space || !room || !radarConfigFromDraft(draft, space.cellCm || 5)) {
options.toast(options.t('radar.invalid'));
return;
}
if (!Array.isArray(room.poly) || room.poly.length < 3) {
options.toast(options.t('radar.no_contour'));
}
if (!options.setup.begin(
device.marker?.id || device.id, radar, room, space.cellCm || 5, options.configRev,
device.marker?.id || device.id, draft, room, space.cellCm || 5, options.configRev,
)) options.toast(options.t('radar.invalid'));
};
const changeInstallation = () => configureOnPlan({
...radar,
installationId: freshRadarInstallationId(),
calibrationOverride: undefined,
inspection: undefined,
inspectError: undefined,
});
const setAxis = (axis: 'xEntities' | 'yEntities', index: number, value: string) => {
const next = [...radar[axis]];
next[index] = value;
@@ -419,9 +438,12 @@ export function renderRadarSection(options: RadarSectionOptions): TemplateResult
<span>${options.t('radar.mirror')}</span>
</label>
<div class="row">
<button class="btn" type="button" @click=${configureOnPlan}>
<button class="btn" type="button" @click=${() => configureOnPlan()}>
<ha-icon icon="mdi:map-marker-radius"></ha-icon>${options.t('radar.configure_on_plan')}
</button>
${radar.original ? html`<button class="btn ghost" type="button" @click=${changeInstallation}>
<ha-icon icon="mdi:restart"></ha-icon>${options.t('radar.change_installation')}
</button>` : nothing}
<button class="btn" type="button" ?disabled=${radar.inspectBusy} @click=${inspect}>
<ha-icon icon="mdi:radar"></ha-icon>
${radar.inspectBusy ? options.t('radar.checking') : options.t('radar.check_sources')}
@@ -435,9 +457,12 @@ export function renderRadarSection(options: RadarSectionOptions): TemplateResult
targets: radar.inspection.frame.targets?.length || 0,
ranges: radar.inspection.frame.ranges?.length || 0,
})}</span>
${(radar.inspection.sources || []).map((source) => html`<code>
${source.entity_id}: ${source.state ?? '—'}
</code>`)}
${(radar.inspection.sources || []).map((source) => {
const age = typeof source.reported_at === 'number' && Number.isFinite(source.reported_at)
? Math.max(0, Math.round(Date.now() / 1000 - source.reported_at)) : null;
return html`<code>${source.entity_id}: ${source.state ?? '—'}${age == null ? ''
: ` · ${options.t('radar.report_age', { seconds: age })}`}</code>`;
})}
</div>` : nothing}
${options.setup.render()}
<div class="row">
+16 -16
View File
@@ -187,11 +187,11 @@ import type {
MarkerValueBadge, ValueBadgePosition, ValueBadgeSource, ZeroWallStyle,
} from './types';
import {
radarConfigFromDraft, radarDraft, recognizeRadar,
radarAfterBindingChange, radarConfigFromDraft, radarDraft, recognizeRadar,
type RadarEditorDraft,
} from './radar-editor';
import { renderRadarSection } from './editors/radar-section';
import { RadarSetupController } from './radar-setup';
import { radarDiscardRequest, RadarSetupController } from './radar-setup';
import {
COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody,
directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
@@ -1231,6 +1231,7 @@ export class HouseplanEditorRuntime {
...host._markerDialog, radar, radarEligible: true, radarTouched: true, radarRemove: false,
};
},
confirmDiscard: () => host._confirmDanger(radarDiscardRequest((key) => host._t(key))),
});
host._editorSecondary = new EditorSecondaryController({
root: () => host.renderRoot as ShadowRoot,
@@ -7327,8 +7328,13 @@ public _openDeviceInbox(): void {
};
}
public _closeMarkerDialog(): void {
this._radarSetup.reset();
public _closeMarkerDialog(): void { void this._closeMarkerDialogGuarded(); }
private async _closeMarkerDialogGuarded(): Promise<void> {
if (!await this._radarSetup.discardIfAllowed()) {
await this.host.updateComplete; this.host.renderRoot
.querySelector<HpDialog>('#marker-dialog')?.rejectClose();
return;
}
this.host._markerDialog = null;
if (this.host._deviceInboxReturn) {
const restored = { ...this.host._deviceInboxReturn };
@@ -12856,10 +12862,7 @@ public _setMarkerGlowMode(mode: 'auto' | 'color' | 'fixed'): void {
};
}
public _renderRadarSection(
d: NonNullable<HouseplanEditorHostPort['_markerDialog']>,
device: DevItem | null,
): TemplateResult {
public _renderRadarSection(d: NonNullable<HouseplanEditorHostPort['_markerDialog']>, device: DevItem | null, placement: 'main' | 'additional' = 'main'): TemplateResult {
return renderRadarSection({
dialog: d,
device,
@@ -12879,7 +12882,7 @@ public _renderRadarSection(
toast: (message) => this.host._showToast(message),
setup: this._radarSetup,
configRev: this.host._cfgRev,
});
}, placement);
}
public _renderMarkerDialog(): TemplateResult {
@@ -12978,7 +12981,7 @@ public _renderMarkerDialog(): TemplateResult {
if (k === 'device') return this.host._fullRegistryHass.devices[ref]?.name_by_user || this.host._fullRegistryHass.devices[ref]?.name || ref;
return this.host._fullRegistryHass.entities[ref]?.name || this.host.hass.states[ref]?.attributes?.friendly_name || ref;
})();
return html`<hp-dialog .hass=${this.host.hass} data-kind="marker"
return html`<hp-dialog id="marker-dialog" .hass=${this.host.hass} data-kind="marker"
.title=${d.devId ? this.host._t('info.device_header') : this.host._t('marker.new_device')}
icon="mdi:shape-plus" wide @hp-close=${() => this._closeMarkerDialog()}>
<div class="body">
@@ -13016,9 +13019,7 @@ public _renderMarkerDialog(): TemplateResult {
...d, bindingMode: 'virtual' as const, binding: 'virtual', bindingOpen: false,
controls: persistedExternalControls('virtual', d.controls),
autoIcon: this.host._autoIconForBinding('virtual'),
radar: null, radarEligible: false,
radarTouched: d.radarTouched || !!d.radar,
radarRemove: d.radarRemove || !!d.radar,
...radarAfterBindingChange(d.radar, d.radarTouched, d.radarRemove),
};
this.host._markerDialog = this._announceToggleDraft({
...next, ...this._valueBadgeForBinding(next, 'virtual'),
@@ -13074,9 +13075,7 @@ public _renderMarkerDialog(): TemplateResult {
c.value, d.controls, this.host._bindingEntities(c.value),
),
autoIcon: this.host._autoIconForBinding(c.value),
radar: null, radarEligible: false,
radarTouched: d.radarTouched || !!d.radar,
radarRemove: d.radarRemove || !!d.radar,
...radarAfterBindingChange(d.radar, d.radarTouched, d.radarRemove),
};
this.host._markerDialog = this._announceToggleDraft({
...next, ...this._valueBadgeForBinding(next, c.value),
@@ -13578,6 +13577,7 @@ public _renderMarkerDialog(): TemplateResult {
@change=${(e: Event) => this._pickMarkerFiles(e)} />
</span>
</div>
${this._renderRadarSection(d, previewDevice, 'additional')}
</div>
<div class="row markerfooter" slot="footer">
<div class="markeractions">
+9
View File
@@ -474,6 +474,10 @@
"radar.show_live": "Live-Ziele anzeigen",
"radar.configure": "Radar konfigurieren",
"radar.configure_on_plan": "Auf dem Plan konfigurieren",
"radar.change_installation": "Installation ändern",
"radar.no_contour": "Dieser Raum hat keine Begrenzung. Radarpunkte werden nicht zugeschnitten.",
"radar.discard_setup_title": "Radareinrichtung verwerfen?",
"radar.discard_setup": "Die nicht gespeicherte Radareinrichtung geht verloren.",
"radar.step_place_mount": "Markieren Sie die physische Sensorposition",
"radar.step_place_heading": "Markieren Sie einen Punkt direkt vor dem Sensor",
"radar.step_reference": "Markieren Sie Referenzposition {n} und starten Sie die Messung",
@@ -488,6 +492,7 @@
"radar.ambiguous_target": "Mehr als ein Ziel wurde gemeldet. Wiederholen Sie die Messung allein.",
"radar.insufficient_samples": "Nicht genügend aktuelle Koordinaten. Wiederholen Sie die Messung oder richten Sie sie manuell ein.",
"radar.bad_references": "Wählen Sie getrennte Referenzen in verschiedenen Richtungen vom Sensor.",
"radar.ambiguous_sources": "Die Messungen lassen zwei gleich plausible Richtungen zu. Wählen Sie Referenzpunkte, die links und rechts unterscheiden.",
"radar.bad_fit": "Die Messungen stimmen nicht überein. Prüfen Sie Einheiten, Sensorposition und Referenzen.",
"radar.desktop_hint": "Verwenden Sie für eine präzise Einrichtung einen Computer. Die Bearbeitung auf Touch-Geräten ist nur eingeschränkt verfügbar.",
"radar.detected": "House Plan hat Radarmetadaten erkannt. Prüfe vor dem Speichern die exakten Quellen.",
@@ -548,6 +553,9 @@
"radar.health_ok": "Aktuelle Daten empfangen",
"radar.health_off": "Keine Ziele erkannt",
"radar.health_stale": "Ein Teil der Koordinaten ist veraltet",
"radar.health_partial": "Einige Quellen sind aktuell, andere veraltet",
"radar.health_restricted": "Der Zugriff auf eine oder mehrere Quellen ist eingeschränkt",
"radar.health_incomplete": "Der aktuelle Radarbericht ist unvollständig",
"radar.health_position_unavailable": "Präsenz gemeldet; aktuelle Position nicht verfügbar",
"radar.health_unavailable": "Sensordaten nicht verfügbar",
"radar.health_needs_setup": "Installation muss eingerichtet werden",
@@ -562,6 +570,7 @@
"radar.error_conflict": "Der Plan wurde geändert. Öffnen Sie die Radareinrichtung erneut und versuchen Sie es noch einmal.",
"radar.error_source_unavailable": "Mindestens eine ausgewählte Radarquelle ist nicht verfügbar.",
"radar.error_unsupported_capability": "Diese Radarfunktion wird von der installierten Integration nicht unterstützt.",
"radar.report_age": "vor {seconds} s",
"space.show_lqi": "Zigbee-Signal (LQI) neben Geräten anzeigen",
"space.hide_decor": "Dekorationsschicht ausblenden",
"space.hide_decor_tip": "Linien, Formen, Beschriftungen und Möbel bleiben an ihrem Platz — im Hintergrundeditor sichtbar, aber nicht auf dem Plan.",
+9
View File
@@ -474,6 +474,10 @@
"radar.show_live": "Show live targets",
"radar.configure": "Configure radar",
"radar.configure_on_plan": "Configure on plan",
"radar.change_installation": "Change installation",
"radar.no_contour": "This room has no boundary. Radar points will not be clipped.",
"radar.discard_setup_title": "Discard radar setup?",
"radar.discard_setup": "The unsaved radar setup will be lost.",
"radar.step_place_mount": "Mark the physical sensor position",
"radar.step_place_heading": "Mark a point straight ahead of the sensor",
"radar.step_reference": "Mark reference position {n}, then start measurement",
@@ -488,6 +492,7 @@
"radar.ambiguous_target": "More than one target was reported. Repeat the measurement alone.",
"radar.insufficient_samples": "Not enough current coordinates. Retry or use manual setup.",
"radar.bad_references": "Choose separated references in different directions from the sensor.",
"radar.ambiguous_sources": "The measurements allow two equally plausible directions. Choose reference points that distinguish left from right.",
"radar.bad_fit": "Measurements do not match. Check units, mounting point and references.",
"radar.desktop_hint": "Use a computer for precise setup. Editing on touch devices is best effort.",
"radar.detected": "House Plan recognized radar metadata for this device. Review the exact sources before saving.",
@@ -548,6 +553,9 @@
"radar.health_ok": "Current data received",
"radar.health_off": "No targets detected",
"radar.health_stale": "Some coordinate data is out of date",
"radar.health_partial": "Some sources are current; others are out of date",
"radar.health_restricted": "Access to one or more sources is restricted",
"radar.health_incomplete": "The current radar report is incomplete",
"radar.health_position_unavailable": "Presence reported; current position unavailable",
"radar.health_unavailable": "Sensor data unavailable",
"radar.health_needs_setup": "Installation needs setup",
@@ -562,6 +570,7 @@
"radar.error_conflict": "The plan changed. Reopen the radar setup and try again.",
"radar.error_source_unavailable": "One or more selected radar sources are unavailable.",
"radar.error_unsupported_capability": "This radar capability is not supported by the installed integration.",
"radar.report_age": "{seconds} s ago",
"space.show_lqi": "Show zigbee signal (LQI) next to devices",
"space.hide_decor": "Hide the decorative layer",
"space.hide_decor_tip": "Lines, shapes, labels and furniture stay where they are — visible in the backdrop editor, not on the plan.",
+9
View File
@@ -474,6 +474,10 @@
"radar.show_live": "Afficher les cibles en direct",
"radar.configure": "Configurer le radar",
"radar.configure_on_plan": "Configurer sur le plan",
"radar.change_installation": "Changer l’installation",
"radar.no_contour": "Cette pièce n’a pas de contour. Les points radar ne seront pas limités.",
"radar.discard_setup_title": "Abandonner la configuration du radar ?",
"radar.discard_setup": "La configuration du radar non enregistrée sera perdue.",
"radar.step_place_mount": "Indiquez la position physique du capteur",
"radar.step_place_heading": "Indiquez un point droit devant le capteur",
"radar.step_reference": "Indiquez la position de référence {n}, puis lancez la mesure",
@@ -488,6 +492,7 @@
"radar.ambiguous_target": "Plusieurs cibles ont été signalées. Recommencez la mesure seul.",
"radar.insufficient_samples": "Coordonnées actuelles insuffisantes. Réessayez ou utilisez le réglage manuel.",
"radar.bad_references": "Choisissez des références espacées dans des directions différentes du capteur.",
"radar.ambiguous_sources": "Les mesures permettent deux directions également plausibles. Choisissez des repères distinguant la gauche de la droite.",
"radar.bad_fit": "Les mesures ne correspondent pas. Vérifiez les unités, la position du capteur et les références.",
"radar.desktop_hint": "Utilisez un ordinateur pour un réglage précis. L’édition sur appareils tactiles est prise en charge au mieux.",
"radar.detected": "House Plan a reconnu les métadonnées radar de cet appareil. Vérifiez les sources exactes avant d’enregistrer.",
@@ -548,6 +553,9 @@
"radar.health_ok": "Données actuelles reçues",
"radar.health_off": "Aucune cible détectée",
"radar.health_stale": "Certaines coordonnées sont obsolètes",
"radar.health_partial": "Certaines sources sont à jour, d’autres sont obsolètes",
"radar.health_restricted": "L’accès à une ou plusieurs sources est limité",
"radar.health_incomplete": "Le rapport radar actuel est incomplet",
"radar.health_position_unavailable": "Présence signalée ; position actuelle indisponible",
"radar.health_unavailable": "Données du capteur indisponibles",
"radar.health_needs_setup": "L’installation doit être configurée",
@@ -562,6 +570,7 @@
"radar.error_conflict": "Le plan a changé. Rouvrez la configuration radar et réessayez.",
"radar.error_source_unavailable": "Une ou plusieurs sources radar sélectionnées sont indisponibles.",
"radar.error_unsupported_capability": "Cette fonction radar n’est pas prise en charge par l’intégration installée.",
"radar.report_age": "il y a {seconds} s",
"space.show_lqi": "Afficher le signal Zigbee (LQI) à côté des appareils",
"space.hide_decor": "Masquer la couche décorative",
"space.hide_decor_tip": "Les lignes, formes, étiquettes et meubles restent à leur place — visibles dans l’éditeur d’arrière-plan, mais pas sur le plan.",
+9
View File
@@ -474,6 +474,10 @@
"radar.show_live": "Показывать живые цели",
"radar.configure": "Настроить радар",
"radar.configure_on_plan": "Настроить на плане",
"radar.change_installation": "Сменить установку",
"radar.no_contour": "У комнаты нет контура. Точки радара не будут обрезаться по комнате.",
"radar.discard_setup_title": "Отменить настройку радара?",
"radar.discard_setup": "Несохранённая настройка радара будет потеряна.",
"radar.step_place_mount": "Укажите физическое положение датчика",
"radar.step_place_heading": "Укажите точку прямо перед датчиком",
"radar.step_reference": "Укажите контрольное положение {n}, затем начните измерение",
@@ -488,6 +492,7 @@
"radar.ambiguous_target": "Обнаружено несколько целей. Повторите измерение в одиночестве.",
"radar.insufficient_samples": "Недостаточно актуальных координат. Повторите или настройте вручную.",
"radar.bad_references": "Выберите разнесённые положения в разных направлениях от датчика.",
"radar.ambiguous_sources": "Измерения допускают два одинаково вероятных направления. Выберите точки, по которым можно различить левую и правую стороны.",
"radar.bad_fit": "Измерения не совпадают. Проверьте единицы, положение датчика и контрольные точки.",
"radar.desktop_hint": "Для точной настройки используйте компьютер. Редактирование на тач-устройствах поддерживается по остаточному принципу.",
"radar.detected": "House Plan распознал признаки радара у этого устройства. Перед сохранением проверьте точные источники.",
@@ -548,6 +553,9 @@
"radar.health_ok": "Актуальные данные получены",
"radar.health_off": "Цели не обнаружены",
"radar.health_stale": "Часть координат устарела",
"radar.health_partial": "Часть источников актуальна, часть устарела",
"radar.health_restricted": "Нет доступа к одному или нескольким источникам",
"radar.health_incomplete": "Текущий отчёт радара неполон",
"radar.health_position_unavailable": "Есть присутствие, но актуальное положение недоступно",
"radar.health_unavailable": "Данные датчика недоступны",
"radar.health_needs_setup": "Требуется настроить установку",
@@ -562,6 +570,7 @@
"radar.error_conflict": "План изменился. Откройте настройку радара заново и повторите попытку.",
"radar.error_source_unavailable": "Один или несколько выбранных источников радара недоступны.",
"radar.error_unsupported_capability": "Установленная интеграция не поддерживает эту возможность радара.",
"radar.report_age": "{seconds} с назад",
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
"space.hide_decor": "Скрыть декоративный слой",
"space.hide_decor_tip": "Линии, фигуры, надписи и мебель остаются на месте — их видно в редакторе подложки, но не на плане.",
+24 -10
View File
@@ -52,6 +52,28 @@ export interface RadarRecognition {
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;
@@ -95,18 +117,12 @@ export function recognizeRadar(
const deviceId = device.bindingKind === 'device'
? device.bindingRef : registryEntity?.device_id;
const registryDevice = deviceId ? registryHass?.devices?.[deviceId] : null;
const haystack = [
device.model, registryDevice?.model, registryDevice?.manufacturer,
].filter(Boolean).join(' ').toLowerCase();
const entities = deviceEntityIds(device);
if (/\bld[-_ ]?2450\b/.test(haystack)
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' };
}
if (/\b(?:mmwave|mm-wave|radar|presence radar|fp2|fp1e|ld24(?:10|12|50))\b/.test(haystack)) {
return { eligible: true, profile: 'presence_v1', reason: 'radar_metadata' };
}
return { eligible: false, profile: 'presence_v1', reason: 'none' };
}
@@ -174,9 +190,7 @@ export function radarDraft(
showLive: original?.show_live !== false,
profile,
roomId,
installationId: original?.mount?.installation_id
|| globalThis.crypto?.randomUUID?.()
|| `radar_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
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,
+14 -3
View File
@@ -15,6 +15,8 @@ export function radarMarkerLiveInSpace(marker: Marker, space: string): boolean {
const RADAR_HEALTH_KEYS: Partial<Record<string, I18nKey>> = {
ok: 'radar.health_ok', off: 'radar.health_off', stale: 'radar.health_stale',
partial: 'radar.health_partial', restricted: 'radar.health_restricted',
incomplete: 'radar.health_incomplete',
position_unavailable: 'radar.health_position_unavailable',
unavailable: 'radar.health_unavailable', needs_setup: 'radar.health_needs_setup',
inconsistent: 'radar.health_inconsistent', unknown: 'radar.health_unknown',
@@ -44,7 +46,12 @@ export interface RadarLiveRange {
segments?: readonly (readonly (readonly [number, number])[])[];
}
export interface RadarLiveZone { id: string; state: boolean | number | null }
export interface RadarLiveZone {
id: string;
state: boolean | number | null;
/** Server-owned geometry for a verified adapter; the client never invents it. */
polygon?: readonly (readonly [number, number])[];
}
export interface RadarLiveFrame {
server_session_id: string;
@@ -98,8 +105,12 @@ export function normalizeRadarFrame(value: unknown): RadarLiveFrame | null {
const item = entry as Record<string, unknown>;
if (typeof item.id !== 'string') return [];
const state = item.state;
return state === null || typeof state === 'boolean' || finite(state)
? [{ id: item.id, state } as RadarLiveZone] : [];
if (!(state === null || typeof state === 'boolean' || finite(state))) return [];
const polygon = Array.isArray(item.polygon) ? item.polygon.slice(0, 64).flatMap((point) => {
if (!Array.isArray(point) || point.length !== 2 || !finite(point[0]) || !finite(point[1])) return [];
return [[point[0], point[1]] as const];
}) : [];
return [{ id: item.id, state, ...(polygon.length >= 3 ? { polygon } : {}) } as RadarLiveZone];
}) : [];
return {
server_session_id: raw.server_session_id,
+11 -2
View File
@@ -13,7 +13,15 @@ export function renderRadarLive(
if (!items.length) return nothing;
const dots: TemplateResult[] = [];
const arcs: TemplateResult[] = [];
const zones: TemplateResult[] = [];
for (const frame of items) {
for (const zone of frame.zones) {
const occupied = zone.state === true || typeof zone.state === 'number' && zone.state > 0;
if (!occupied || !zone.polygon) continue;
const projected = zone.polygon.map((point) => project([point[0] * 1000, point[1] * 1000]));
zones.push(svg`<polygon class="radar-zone" data-radar-zone=${zone.id}
points=${projected.map((point) => `${point[0]},${point[1]}`).join(' ')}></polygon>`);
}
for (const range of frame.ranges) {
// An empty authoritative segment list means clipping removed the whole
// arc. Never resurrect it with an unclipped client-side fallback.
@@ -34,7 +42,8 @@ export function renderRadarLive(
aria-hidden="true"></span>`);
}
}
return html`${arcs.length ? svg`<svg class="radar-ranges" data-hp-live-viewbox="camera"
if (!arcs.length && !zones.length && !dots.length) return nothing;
return html`${arcs.length || zones.length ? svg`<svg class="radar-ranges" data-hp-live-viewbox="camera"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="none"
aria-hidden="true">${arcs}</svg>` : nothing}${dots}`;
aria-hidden="true">${zones}${arcs}</svg>` : nothing}${dots}`;
}
+17 -4
View File
@@ -7,6 +7,14 @@ import {
projectRadarLocal, radarMedianSample, solveRadarTwoPoint,
} from './radar-geometry';
import type { RadarEditorDraft } from './radar-editor';
type RadarDiscardKey = 'radar.discard_setup_title' | 'radar.discard_setup' | 'btn.close' | 'btn.cancel';
export const radarDiscardRequest = (t: (key: RadarDiscardKey) => string) => ({
key: 'discard-radar-setup', kind: 'warning' as const,
title: t('radar.discard_setup_title'), message: t('radar.discard_setup'),
confirmLabel: t('btn.close'), cancelLabel: t('btn.cancel'),
});
import type { MarkerRadar, RoomCfg } from './types';
const COUNTDOWN_MS = 10_000;
@@ -63,6 +71,7 @@ export interface RadarSetupHost {
t(key: I18nKey, vars?: Record<string, string | number>): string;
configFromDraft(draft: RadarEditorDraft, cellCm: number): MarkerRadar | null;
apply(draft: RadarEditorDraft): void;
confirmDiscard(): Promise<boolean>;
}
function pointFromEvent(event: PointerEvent): Point | null {
@@ -118,13 +127,17 @@ export class RadarSetupController {
this.active = null;
}
public cancel(): boolean {
if (!this.active) return false;
public async discardIfAllowed(): Promise<boolean> {
if (!this.active) return true;
const dirty = this.active.mount !== null || this.active.phase !== 'mount';
if (dirty && !await this.host.confirmDiscard()) return false;
this.reset();
this.host.requestUpdate();
return true;
}
public cancel(): Promise<boolean> { return this.discardIfAllowed(); }
public isActive(): boolean { return this.active !== null; }
public syncMarker(markerId: string): void {
@@ -313,7 +326,7 @@ export class RadarSetupController {
state.error = undefined;
} catch (error: unknown) {
const message = String((error as Error)?.message);
state.error = message === 'ambiguous_sources' ? 'radar.bad_references' : 'radar.bad_fit';
state.error = message === 'ambiguous_sources' ? 'radar.ambiguous_sources' : 'radar.bad_fit';
state.refs = [];
state.phase = 'reference_1';
}
@@ -386,7 +399,7 @@ export class RadarSetupController {
<div class="radarsetup-head">
<strong>${this.instruction(state)}</strong>
<button class="iconbtn" type="button" title=${this.host.t('btn.cancel')}
@click=${() => this.cancel()}><ha-icon icon="mdi:close"></ha-icon></button>
@click=${() => { void this.cancel(); }}><ha-icon icon="mdi:close"></ha-icon></button>
</div>
<svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet"
@pointerdown=${(event: PointerEvent) => this.choosePoint(event)}
+7
View File
@@ -1445,6 +1445,13 @@ export const planStyles = css`
vector-effect: non-scaling-stroke;
opacity: 0.72;
}
.radar-ranges .radar-zone {
fill: color-mix(in srgb, var(--success-color, #43a047) 8%, transparent);
stroke: var(--success-color, #43a047);
stroke-width: 2px;
vector-effect: non-scaling-stroke;
opacity: 0.8;
}
.stage.mode-devices .dev { cursor: grab; }
.stage.mode-devices .dev:active { cursor: grabbing; }
.physical-hit {
-8
View File
@@ -124,10 +124,6 @@ export interface RadarRangeSource {
export interface RadarZoneSource {
id: string; kind: 'occupancy' | 'count'; entity_id: string;
}
export interface RadarLocalZone {
id: string; name: string; poly: RadarPoint[];
state: { kind: 'targets' } | { kind: 'occupancy' | 'count'; entity_id: string };
}
export interface MarkerRadar {
version: 1;
enabled: boolean;
@@ -150,8 +146,6 @@ export interface MarkerRadar {
method: 'manual' | 'two_point' | 'not_required'; mirror: boolean; cell_cm: number;
refs?: { plan: RadarPoint; local_cm: RadarPoint }[]; rms_cm?: number;
};
zones?: { local?: RadarLocalZone[]; hardware?: Record<string, unknown> };
reflectors?: { id: string; name: string; a: RadarPoint; b: RadarPoint; enabled: boolean }[];
allowed_room_ids?: string[];
[key: string]: unknown;
}
@@ -314,8 +308,6 @@ export interface ServerConfig {
radar?: {
version?: 1;
show_live?: boolean;
fusion_groups?: unknown[];
room_outputs?: unknown[];
[key: string]: unknown;
};
};