diff --git a/custom_components/houseplan/radar.py b/custom_components/houseplan/radar.py index b25f95b1..35373004 100644 --- a/custom_components/houseplan/radar.py +++ b/custom_components/houseplan/radar.py @@ -31,7 +31,11 @@ from .radar_geometry import ( polygon_is_convex, project_local, ) -from .radar_validation import radar_source_entity_ids, validate_radar_draft +from .radar_validation import ( + radar_registry_evidence, + radar_source_entity_ids, + validate_radar_draft, +) from .store import HouseplanData MAX_FRAME_HZ = 4 @@ -103,6 +107,8 @@ class RadarCoordinator: async def async_setup(self) -> None: await self.async_refresh() + if self.closed: + return @callback def config_updated(_event: Event) -> None: @@ -117,7 +123,10 @@ class RadarCoordinator: if self.closed: return stored = await self.runtime.config_store.async_load() or {} + if self.closed: + return config = stored.get("config") or {} + registry = radar_registry_evidence(self.hass) self.config = config self.config_rev = int(stored.get("rev", 0)) self.radars = {} @@ -129,7 +138,7 @@ class RadarCoordinator: continue marker_id = str(marker.get("id")) try: - validate_radar_draft(config, marker_id, marker["radar"]) + validate_radar_draft(config, marker_id, marker["radar"], registry) except vol.Invalid: # Change-aware config compatibility may retain an old or # future-invalid block. It stays lossless but never runs. @@ -540,7 +549,9 @@ class RadarCoordinator: if occupancy is True and not frame["targets"] and not frame["ranges"]: frame["health"] = "position_unavailable" elif any_stale: - frame["health"] = "stale" + has_current_evidence = bool(frame["ranges"]) if profile == "range_v1" \ + else explicit_slots > 0 + frame["health"] = "partial" if has_current_evidence else "stale" expires = [item["expires_at"] for item in [*frame["targets"], *frame["ranges"]]] frame["expires_at"] = min(expires) if expires else now count = _numeric_state(self.hass.states.get(sources.get("count_entity"))) \ diff --git a/custom_components/houseplan/radar_validation.py b/custom_components/houseplan/radar_validation.py index 9510f562..19c24322 100644 --- a/custom_components/houseplan/radar_validation.py +++ b/custom_components/houseplan/radar_validation.py @@ -26,6 +26,7 @@ ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") ENTITY_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$") MAX_RADARS = 32 CANVAS_LIMIT = 5000.0 +LD2450_MODELS = frozenset({"ld2450", "hlkld2450", "hilinkld2450"}) class RadarValidationError(vol.Invalid): @@ -111,24 +112,74 @@ def radar_source_entity_ids(radar: Any) -> set[str]: return set() sources = radar.get("sources") out = _source_ids(sources) if isinstance(sources, dict) else set() - zones = radar.get("zones") - if isinstance(zones, dict): - for item in zones.get("local") or []: - state = item.get("state") if isinstance(item, dict) else None - if isinstance(state, dict) and isinstance(state.get("entity_id"), str): - out.add(state["entity_id"]) - hardware = zones.get("hardware") - if isinstance(hardware, dict): - for value in hardware.values(): - if isinstance(value, str) and "." in value: - out.add(value) - for slot in hardware.get("slots") or []: - if isinstance(slot, dict): - out.update(value for key, value in slot.items() - if key.endswith("_entity") and isinstance(value, str)) return out +def radar_registry_evidence(hass: Any) -> dict[str, dict[str, dict[str, Any]]]: + """Capture the small registry subset needed by verified adapters. + + The returned plain mapping is safe to pass into executor-side validation. + Generic/manual profiles deliberately do not depend on registry evidence. + """ + from homeassistant.helpers import device_registry as dr + from homeassistant.helpers import entity_registry as er + + entities = { + str(entry.entity_id): { + "device_id": str(entry.device_id) if entry.device_id else None, + "platform": str(getattr(entry, "platform", "") or ""), + } + for entry in er.async_get(hass).entities.values() + } + devices = { + str(entry.id): { + "model": str(getattr(entry, "model", "") or ""), + } + for entry in dr.async_get(hass).devices.values() + } + return {"entities": entities, "devices": devices} + + +def _canonical_model(value: Any) -> str: + return re.sub(r"[^a-z0-9]", "", str(value or "").lower()) + + +def _validate_verified_adapter( + profile: str, + sources: dict[str, Any], + marker: dict[str, Any], + registry: dict[str, dict[str, dict[str, Any]]] | None, +) -> None: + """Require structural same-device evidence for the LD2450 adapter.""" + if profile != "esphome_ld2450_v1" or registry is None: + return + entities = registry.get("entities") or {} + source_ids = _source_ids(sources) + rows = [entities.get(entity_id) for entity_id in source_ids] + device_ids = { + str(row.get("device_id")) + for row in rows + if isinstance(row, dict) and row.get("device_id") + } + if len(rows) != len(source_ids) or any(not isinstance(row, dict) for row in rows) \ + or len(device_ids) != 1 \ + or any(str(row.get("platform") or "") != "esphome" for row in rows if row): + _invalid("LD2450 sources must belong to the same ESPHome device") + device_id = next(iter(device_ids)) + model = (registry.get("devices") or {}).get(device_id, {}).get("model") + if _canonical_model(model) not in LD2450_MODELS: + _invalid("LD2450 adapter requires verified LD2450 device metadata") + binding = str(marker.get("binding") or "") + owner_device: str | None = None + if binding.startswith("device:"): + owner_device = binding.removeprefix("device:") + elif binding.startswith("entity:"): + owner = entities.get(binding.removeprefix("entity:")) + owner_device = str(owner.get("device_id")) if isinstance(owner, dict) and owner.get("device_id") else None + if owner_device != device_id: + _invalid("LD2450 sources must belong to the marker device") + + def _validate_sources(profile: str, sources: Any) -> None: obj = _mapping(sources, "radar.sources") slots = _array(obj.get("slots", []), "radar.sources.slots", 8) @@ -215,81 +266,12 @@ def _validate_sources(profile: str, sources: Any) -> None: _entity(obj["availability_entity"], "radar.sources.availability_entity", {"binary_sensor"}) -def _polygon(points: Any, name: str) -> None: - items = _array(points, name, 64) - if len(items) < 3: - _invalid(f"{name} requires at least three points") - parsed = [_point(point, name) for point in items] - if any(parsed[index] == parsed[(index + 1) % len(parsed)] for index in range(len(parsed))): - _invalid(f"{name} contains duplicate adjacent points") - area = sum(parsed[i][0] * parsed[(i + 1) % len(parsed)][1] - - parsed[(i + 1) % len(parsed)][0] * parsed[i][1] - for i in range(len(parsed))) / 2 - if abs(area) <= 1e-9: - _invalid(f"{name} has zero area") - - -def _validate_stage2(radar: dict[str, Any]) -> None: - zones = radar.get("zones") - if zones is not None: - obj = _mapping(zones, "radar.zones") - local = _array(obj.get("local", []), "radar.zones.local", 32) - seen: set[str] = set() - for index, zone in enumerate(local): - item = _mapping(zone, f"radar.zones.local[{index}]") - ident = _identifier(item.get("id"), "radar zone id") - if ident in seen: - _invalid("radar zone ids must be unique") - seen.add(ident) - if not isinstance(item.get("name"), str) or not 1 <= len(item["name"].strip()) <= 80: - _invalid("radar zone name is invalid") - _polygon(item.get("poly"), "radar zone polygon") - state = _mapping(item.get("state"), "radar zone state") - kind = state.get("kind") - if kind not in {"targets", "occupancy", "count"}: - _invalid("radar zone state kind is invalid") - if kind != "targets": - _entity(state.get("entity_id"), "radar zone state entity", - {"binary_sensor"} if kind == "occupancy" else {"sensor"}) - hardware = obj.get("hardware") - if hardware is not None: - item = _mapping(hardware, "radar.zones.hardware") - if item.get("adapter") != "esphome_ld2450_numbers_v1": - _invalid("unsupported radar hardware adapter") - _entity(item.get("mode_entity"), "radar hardware mode", {"select"}) - slots = _array(item.get("slots"), "radar hardware slots", 3) - if len(slots) != 3: - _invalid("radar hardware requires three slots") - entity_ids: set[str] = {item["mode_entity"]} - for expected, slot in enumerate(slots, 1): - entry = _mapping(slot, "radar hardware slot") - if entry.get("slot") != expected: - _invalid("radar hardware slots must be ordered 1..3") - for key in ("x1_entity", "y1_entity", "x2_entity", "y2_entity"): - entity_id = _entity(entry.get(key), f"radar hardware {key}", {"number"}) - if entity_id in entity_ids: - _invalid("radar hardware entities must be distinct") - entity_ids.add(entity_id) - - reflectors = _array(radar.get("reflectors", []), "radar.reflectors", 8) - seen_reflectors: set[str] = set() - for item in reflectors: - line = _mapping(item, "radar reflector") - ident = _identifier(line.get("id"), "radar reflector id") - if ident in seen_reflectors: - _invalid("radar reflector ids must be unique") - seen_reflectors.add(ident) - if not isinstance(line.get("name"), str) or not 1 <= len(line["name"].strip()) <= 80: - _invalid("radar reflector name is invalid") - if not isinstance(line.get("enabled"), bool): - _invalid("radar reflector enabled must be boolean") - a = _point(line.get("a"), "radar reflector a") - b = _point(line.get("b"), "radar reflector b") - if a == b: - _invalid("radar reflector has zero length") - - -def _validate_radar(radar: Any, spaces: dict[str, dict[str, Any]], marker: dict[str, Any]) -> None: +def _validate_radar( + radar: Any, + spaces: dict[str, dict[str, Any]], + marker: dict[str, Any], + registry: dict[str, dict[str, dict[str, Any]]] | None = None, +) -> None: obj = _mapping(radar, "marker.radar") if obj.get("version") != 1: _invalid("unsupported radar version") @@ -301,6 +283,7 @@ def _validate_radar(radar: Any, spaces: dict[str, dict[str, Any]], marker: dict[ if profile not in RADAR_PROFILES: _invalid("unsupported radar profile") _validate_sources(profile, obj.get("sources")) + _validate_verified_adapter(profile, obj["sources"], marker, registry) room_id = obj.get("room_id") if not isinstance(room_id, str) or not room_id: _invalid("radar.room_id is required") @@ -364,7 +347,6 @@ def _validate_radar(radar: Any, spaces: dict[str, dict[str, Any]], marker: dict[ _invalid("manual calibration cannot contain captured references") if calibration.get("rms_cm") is not None: _finite(calibration["rms_cm"], "radar calibration rms_cm", 0, 30) - _validate_stage2(obj) allowed = obj.get("allowed_room_ids") if allowed is not None: room_ids = _array(allowed, "radar.allowed_room_ids", 32) @@ -373,54 +355,17 @@ def _validate_radar(radar: Any, spaces: dict[str, dict[str, Any]], marker: dict[ _invalid("radar allowed rooms must be unique rooms in the marker space") -def _validate_settings(settings: Any, spaces: dict[str, dict[str, Any]], - markers: dict[str, dict[str, Any]]) -> None: +def _validate_settings(settings: Any) -> None: obj = _mapping(settings, "settings.radar") if obj.get("version") not in (None, 1): _invalid("unsupported radar settings version") if obj.get("show_live") is not None and not isinstance(obj.get("show_live"), bool): _invalid("settings.radar.show_live must be boolean") - groups = _array(obj.get("fusion_groups", []), "settings.radar.fusion_groups", 32) - group_ids: set[str] = set() - used_markers: set[str] = set() - for group in groups: - item = _mapping(group, "radar fusion group") - ident = _identifier(item.get("id"), "radar fusion group id") - if ident in group_ids: - _invalid("radar fusion group ids must be unique") - group_ids.add(ident) - if not isinstance(item.get("enabled"), bool): - _invalid("radar fusion group enabled must be boolean") - space_id = item.get("space_id") - if space_id not in spaces: - _invalid("radar fusion group space is invalid") - marker_ids = _array(item.get("marker_ids"), "radar fusion marker_ids", 8) - if len(marker_ids) < 2 or len(set(marker_ids)) != len(marker_ids): - _invalid("radar fusion group requires 2..8 unique markers") - for marker_id in marker_ids: - marker = markers.get(str(marker_id)) - if marker is None or marker.get("space") != space_id or marker_id in used_markers: - _invalid("radar fusion marker ownership is invalid") - used_markers.add(str(marker_id)) - outputs = _array(obj.get("room_outputs", []), "settings.radar.room_outputs", 64) - output_ids: set[str] = set() - for output in outputs: - item = _mapping(output, "radar room output") - ident = _identifier(item.get("id"), "radar room output id") - if ident in output_ids: - _invalid("radar room output ids must be unique") - output_ids.add(ident) - space = spaces.get(str(item.get("space_id"))) - if space is None or str(item.get("room_id")) not in { - str(room.get("id")) for room in space.get("rooms") or [] - }: - _invalid("radar room output owner is invalid") - if item.get("presence") not in (None, True, False) or item.get("estimated_count") not in (None, True, False): - _invalid("radar room output flags must be boolean") def validate_marker_radars(config: dict[str, Any], previous: dict[str, Any] | None = None, - *, validate_all: bool = False) -> None: + *, validate_all: bool = False, + registry: dict[str, dict[str, dict[str, Any]]] | None = None) -> None: """Validate only newly created/changed known radar namespaces. This mirrors the project's lossless compatibility doctrine: untouched @@ -442,7 +387,7 @@ def validate_marker_radars(config: dict[str, Any], previous: dict[str, Any] | No configured += 1 old = previous_markers.get(marker_id, {}).get("radar", object()) if validate_all or radar != old: - _validate_radar(radar, spaces, marker) + _validate_radar(radar, spaces, marker, registry) if configured > MAX_RADARS: _invalid("at most 32 radars may be configured") @@ -450,11 +395,12 @@ def validate_marker_radars(config: dict[str, Any], previous: dict[str, Any] | No settings = (config.get("settings") or {}).get("radar", missing) previous_settings = ((previous or {}).get("settings") or {}).get("radar", missing) if settings is not missing and (validate_all or settings != previous_settings): - _validate_settings(settings, spaces, markers) + _validate_settings(settings) def validate_radar_draft( config: dict[str, Any], marker_id: str, radar: Any, + registry: dict[str, dict[str, dict[str, Any]]] | None = None, ) -> tuple[dict[str, Any], set[str]]: """Validate an unsaved setup block against its exact stored marker owner.""" marker = next( @@ -467,5 +413,5 @@ def validate_radar_draft( candidate = copy.deepcopy(marker) candidate["radar"] = copy.deepcopy(radar) spaces = {str(space.get("id")): space for space in config.get("spaces") or []} - _validate_radar(candidate["radar"], spaces, candidate) + _validate_radar(candidate["radar"], spaces, candidate, registry) return candidate, radar_source_entity_ids(candidate["radar"]) diff --git a/custom_components/houseplan/radar_websocket.py b/custom_components/houseplan/radar_websocket.py index 9f8088b5..955856c1 100644 --- a/custom_components/houseplan/radar_websocket.py +++ b/custom_components/houseplan/radar_websocket.py @@ -18,7 +18,11 @@ from homeassistant.helpers.event import ( from .auth import may_write from .radar import MAX_FRAME_HZ, RadarCoordinator -from .radar_validation import RadarValidationError, validate_radar_draft +from .radar_validation import ( + RadarValidationError, + radar_registry_evidence, + validate_radar_draft, +) from .store import get_data _INSPECT_CALLS: dict[str, deque[float]] = defaultdict(deque) @@ -41,6 +45,7 @@ def _validated_draft( raise vol.Invalid("radar draft is too large") marker, source_ids = validate_radar_draft( coordinator.config, message["marker_id"], radar, + radar_registry_evidence(coordinator.hass), ) except RadarValidationError: connection.send_error(message["id"], "invalid_radar", "invalid_radar") @@ -51,12 +56,22 @@ def _validated_draft( if not _can_read(connection, source_ids): connection.send_error(message["id"], "source_restricted", "source_restricted") return None + if any( + coordinator.hass.states.get(entity_id) is None + or str(coordinator.hass.states.get(entity_id).state) in {"unknown", "unavailable"} + for entity_id in source_ids + ): + connection.send_error(message["id"], "source_unavailable", "source_unavailable") + return None return marker, radar, source_ids def _coordinator(hass: HomeAssistant, connection, msg_id: int) -> RadarCoordinator | None: runtime = get_data(hass) coordinator = getattr(runtime, "radar_coordinator", None) if runtime else None + if runtime is not None and coordinator is None: + connection.send_error(msg_id, "unsupported_capability", "unsupported_capability") + return None if not isinstance(coordinator, RadarCoordinator) or coordinator.closed: connection.send_error(msg_id, "not_ready", "not_ready") return None diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index dd01119c..7ff76f41 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -81,7 +81,11 @@ from .plans import ( reserve_filename, ) from .projection import project_config, project_layout -from .radar_validation import RadarValidationError, validate_marker_radars +from .radar_validation import ( + RadarValidationError, + radar_registry_evidence, + validate_marker_radars, +) from .registry_snapshot import import_registry_snapshot from .store import ( LAYOUT_STORE_CORE_KEYS, @@ -1650,6 +1654,8 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> baseline_counts = baseline[1] if baseline and baseline[0] == current_rev else None readable_entity_ids = _readable_entity_ids(hass, connection) + radar_registry = radar_registry_evidence(hass) + def _validate_config_cpu(): def _normalize(candidate): validate_wall_model_transition(candidate, data.get("config")) @@ -1662,7 +1668,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> msg["config"].update(checked) validate_marker_controls(msg["config"], data.get("config")) validate_marker_light_entities(msg["config"], data.get("config")) - validate_marker_radars(msg["config"], data.get("config")) + validate_marker_radars( + msg["config"], data.get("config"), registry=radar_registry, + ) validate_marker_value_badges(msg["config"], data.get("config")) validate_marker_vacuum_routes(msg["config"], data.get("config")) validate_opening_passages(msg["config"], data.get("config")) @@ -2014,6 +2022,8 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) # #330 §4.1: the same executor treatment as config/set — Optimize # carries schema + a possible full migration, the costliest CPU path # of all writers, and it used to run on the event loop. + optimize_radar_registry = radar_registry_evidence(hass) + def _validate_optimize_cpu(): def _normalize(candidate): validate_wall_model_transition(candidate, config_data.get("config")) @@ -2046,7 +2056,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) msg["config"].update(checked) validate_marker_controls(msg["config"], config_data.get("config")) validate_marker_light_entities(msg["config"], config_data.get("config")) - validate_marker_radars(msg["config"], config_data.get("config")) + validate_marker_radars( + msg["config"], config_data.get("config"), + registry=optimize_radar_registry, + ) validate_marker_value_badges(msg["config"], config_data.get("config")) validate_marker_vacuum_routes(msg["config"], config_data.get("config")) validate_opening_passages(msg["config"], config_data.get("config")) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 99554add..393844c2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -45,7 +45,10 @@ House Plan recognizes ESPHome LD2450 coordinate sets, also supports explicit Cartesian, polar, range, zone-state and presence-only mappings, and provides an on-plan installation/calibration flow with source-health diagnostics. Raw - observations and the short diagnostic trail remain session-only + observations and the short diagnostic trail remain session-only. LD2450 + auto-detection now requires an exact ESPHome, same-device registry match; + changing a saved binding keeps its radar setup available for repair, while + setup cancellation and source-health states are reported consistently ([#485](https://github.com/Matysh/houseplan-card/issues/485)). ## v1.73.0-beta.6 — 2026-09-08 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 38051cc6..3e7322fd 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -56,7 +56,11 @@ наборы ESPHome LD2450, поддерживает явное сопоставление декартовых, полярных, дальностных, зональных и presence-only источников и проводит установку и калибровку прямо на плане с диагностикой состояния данных. Сырые наблюдения и - короткий диагностический след остаются только в текущей сессии + короткий диагностический след остаются только в текущей сессии. Автоопределение + LD2450 теперь требует точного совпадения модели и общего устройства ESPHome; + смена сохранённой привязки не удаляет настройку радара, оставляя её доступной + для исправления, а отмена установки и статусы источников обрабатываются + согласованно ([#485](https://github.com/Matysh/houseplan-card/issues/485)). ## v1.73.0-beta.6 — 2026-09-08 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index b96800f1..d440a18c 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -750,7 +750,7 @@ unavailable sources and presence without a usable position. Live dots and range arcs do not intercept clicks and are clipped to the selected room. Their short trail and smoothing exist only in the open browser session; House Plan does not save raw radar samples or target history. Disable either -the radar itself or **General settings → Show presence on the plan** to hide the +the radar itself or **General settings → Show live presence on the plan** to hide the layer. See [Presence radars](RADAR.md) for supported profiles, setup and privacy. ## 11. Tap actions diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 8a331257..8c8b78ae 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1107,7 +1107,7 @@ HA. «Показать» заблокировано до активации. П Живые точки и дуги дальности не перехватывают клики и обрезаются по выбранной комнате. Короткий след и сглаживание живут только в открытой сессии браузера: сырые измерения и история целей не сохраняются. Слой можно выключить у -конкретного радара или глобально в **«Общие настройки → Показывать присутствие +конкретного радара или глобально в **«Общие настройки → Показывать текущее присутствие на плане»**. Подробности о профилях, настройке и приватности — в [документе о радарах присутствия](RADAR.md). diff --git a/src/editors/radar-section.ts b/src/editors/radar-section.ts index e7f58c6f..52c49457 100644 --- a/src/editors/radar-section.ts +++ b/src/editors/radar-section.ts @@ -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`
+ ${options.t('radar.additional_actions')} + +
`; + } if ((!d.radar || d.radarRemove) && options.setup.isActive()) options.setup.reset(); if (savedUnsupported || d.bindingMode === 'virtual' && !!device.marker?.radar) { return html`
@@ -72,16 +97,6 @@ export function renderRadarSection(options: RadarSectionOptions): TemplateResult
`; } 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`
@@ -93,12 +108,7 @@ export function renderRadarSection(options: RadarSectionOptions): TemplateResult
`; } - return html`
- ${options.t('radar.additional_actions')} - -
`; + 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 ${options.t('radar.mirror')}
- + ${radar.original ? html`` : nothing}
` : nothing} ${options.setup.render()}
diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 2722a22f..88471477 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -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 { + if (!await this._radarSetup.discardIfAllowed()) { + await this.host.updateComplete; this.host.renderRoot + .querySelector('#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, - device: DevItem | null, -): TemplateResult { +public _renderRadarSection(d: NonNullable, 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` this._closeMarkerDialog()}>
@@ -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)} />
+ ${this._renderRadarSection(d, previewDevice, 'additional')}
diff --git a/src/i18n/de.json b/src/i18n/de.json index 6ddbe512..bfb2cfb8 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -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.", diff --git a/src/i18n/en.json b/src/i18n/en.json index e37523fb..7ed5236b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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.", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index eee73c90..7dbdcd8a 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -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.", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 85042f95..2d81dc00 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -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": "Линии, фигуры, надписи и мебель остаются на месте — их видно в редакторе подложки, но не на плане.", diff --git a/src/radar-editor.ts b/src/radar-editor.ts index 93f56cd9..c74fd07a 100644 --- a/src/radar-editor.ts +++ b/src/radar-editor.ts @@ -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, diff --git a/src/radar-model.ts b/src/radar-model.ts index 16c6efcc..8182cc26 100644 --- a/src/radar-model.ts +++ b/src/radar-model.ts @@ -15,6 +15,8 @@ export function radarMarkerLiveInSpace(marker: Marker, space: string): boolean { const RADAR_HEALTH_KEYS: Partial> = { 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; 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, diff --git a/src/radar-render.ts b/src/radar-render.ts index adc9f3de..c274df29 100644 --- a/src/radar-render.ts +++ b/src/radar-render.ts @@ -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` `${point[0]},${point[1]}`).join(' ')}>`); + } 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">`); } } - return html`${arcs.length ? svg`` : nothing}${dots}`; + aria-hidden="true">${zones}${arcs}` : nothing}${dots}`; } diff --git a/src/radar-setup.ts b/src/radar-setup.ts index 84e717ff..c1114099 100644 --- a/src/radar-setup.ts +++ b/src/radar-setup.ts @@ -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; configFromDraft(draft: RadarEditorDraft, cellCm: number): MarkerRadar | null; apply(draft: RadarEditorDraft): void; + confirmDiscard(): Promise; } 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 { + 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 { 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 {
${this.instruction(state)} + @click=${() => { void this.cancel(); }}>
this.choosePoint(event)} diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index e29497c8..7ad93f71 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -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 { diff --git a/src/types.ts b/src/types.ts index 14e0e2f5..a3952db0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 }; - 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; }; }; diff --git a/test/radar-editor.test.mjs b/test/radar-editor.test.mjs index 5f3ca7ee..ffb0bc08 100644 --- a/test/radar-editor.test.mjs +++ b/test/radar-editor.test.mjs @@ -2,7 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - radarConfigFromDraft, radarDraft, radarSourceCandidates, recognizeRadar, + freshRadarInstallationId, radarAfterBindingChange, radarConfigFromDraft, radarDraft, + radarSourceCandidates, recognizeRadar, } from '../test-build/radar-editor.js'; const space = { id: 'floor', title: 'Floor', cellCm: 5, rooms: [ @@ -50,6 +51,22 @@ test('coordinate-looking entity names alone do not claim an LD2450 adapter', () 'sensor.machine_target_1_x', 'sensor.machine_target_1_y', ] }); assert.equal(recognizeRadar(ordinary, {}).eligible, false); + const fuzzy = device({ model: 'Presence Radar FP2', allEntities: [ + 'sensor.machine_target_1_x', 'sensor.machine_target_1_y', + ] }); + assert.equal(recognizeRadar(fuzzy, {}).eligible, false); +}); + +test('rebinding preserves only an already-saved radar and a new installation gets a new id', () => { + const pending = radarDraft(device(), space, [50, 60], {}, true); + assert.deepEqual(radarAfterBindingChange(pending, true, false), { + radar: null, radarEligible: false, radarTouched: true, radarRemove: true, + }); + const saved = { ...pending, original: { version: 1 } }; + assert.deepEqual(radarAfterBindingChange(saved, false, false), { + radar: saved, radarEligible: true, radarTouched: false, radarRemove: false, + }); + assert.notEqual(freshRadarInstallationId(), freshRadarInstallationId()); }); test('manual exact sources may come from separate HA devices', () => { diff --git a/test/radar-geometry.test.mjs b/test/radar-geometry.test.mjs index eba4ae35..e3355db6 100644 --- a/test/radar-geometry.test.mjs +++ b/test/radar-geometry.test.mjs @@ -37,6 +37,27 @@ test('two reference solve keeps physical scale rigid', () => { ], 5), /invalid_selection/); }); +test('two-point calibration keeps every distance and fit guard distinct', () => { + const mount = [.5, .5]; + const plan = (x, y) => [.5 + x / 1200, .5 - y / 1200]; + assert.throws(() => solveRadarTwoPoint(mount, [[49, 0], [0, 100]], [ + plan(49, 0), plan(0, 100), + ], 5), /invalid_selection/, 'references under 50 cm are rejected'); + assert.throws(() => solveRadarTwoPoint(mount, [[100, 0], [0, 100]], [ + plan(100, 0), plan(-50, 86.603), + ], 5), /invalid_selection/, 'RMS over 20 cm is rejected'); + assert.throws(() => solveRadarTwoPoint(mount, [[100, 0], [0, 100]], [ + plan(100, 0), plan(-64.279, 76.604), + ], 5), /invalid_selection/, 'an individual error over 30 cm is rejected'); +}); + +test('two-point calibration exposes an ambiguous mirror instead of choosing silently', () => { + const plan = (x, y) => [.5 + x / 1200, .5 - y / 1200]; + assert.throws(() => solveRadarTwoPoint([.5, .5], [[-20, 80], [20, 80]], [ + plan(0, 80), plan(0, 80), + ], 5), /ambiguous_sources/); +}); + test('capture median rejects one sample more than 15cm away', () => { assert.deepEqual(radarMedianSample([[100, 200], [102, 198], [101, 201]]), [101, 200]); assert.throws(() => radarMedianSample([[100, 200], [101, 199], [140, 240]]), /bad_fit/); diff --git a/test/radar-model.test.mjs b/test/radar-model.test.mjs index bdf6848e..9cf83c90 100644 --- a/test/radar-model.test.mjs +++ b/test/radar-model.test.mjs @@ -26,10 +26,14 @@ test('radar frames reject malformed geometry and cap nested payloads', () => { ], ranges: [{ id: 'r', x: 10, y: 20, radius: 3, reported_at: 100, expires_at: 103, segments: [[[1, 2], [3, 4]], [[Number.NaN, 2], [3, 4]]] }], + zones: [{ id: 'desk', state: true, polygon: [[.1, .1], [.3, .1], [.3, .3]] }, + { id: 'broken', state: true, polygon: [[.1, Number.NaN], [.2, .2], [.3, .3]] }], })); assert.ok(normalized); assert.equal(normalized.targets.length, 1); assert.deepEqual(normalized.ranges[0].segments, [[[1, 2], [3, 4]]]); + assert.deepEqual(normalized.zones[0].polygon, [[.1, .1], [.3, .1], [.3, .3]]); + assert.equal(normalized.zones[1].polygon, undefined); assert.equal(normalizeRadarFrame({ marker_id: 'x' }), null); }); @@ -84,6 +88,9 @@ test('radar health respects disabled config before runtime state', () => { const radar = { version: 1, enabled: false, show_live: true }; assert.equal(radarHealthI18nKey(radar, 'ok'), 'radar.health_disabled'); assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'ok'), 'radar.health_ok'); + assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'partial'), 'radar.health_partial'); + assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'restricted'), 'radar.health_restricted'); + assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'incomplete'), 'radar.health_incomplete'); assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'future_status'), 'radar.health_unknown'); }); diff --git a/test/radar-render.test.mjs b/test/radar-render.test.mjs new file mode 100644 index 00000000..3d9f6c1c --- /dev/null +++ b/test/radar-render.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { renderRadarLive } from '../test-build/radar-render.js'; + +const frame = (state) => ({ + marker_id: 'radar', targets: [], ranges: [], + zones: [{ id: 'desk', state, polygon: [[.1, .1], [.3, .1], [.3, .3]] }], +}); + +test('known occupied radar zones render server-owned geometry only', () => { + const occupied = renderRadarLive([frame(true)], { x: 0, y: 0, w: 1000, h: 1000 }, (p) => p); + const zone = occupied.values[0].values[4][0]; + assert.match(zone.strings.join(''), /class="radar-zone"/); + assert.equal(zone.values[0], 'desk'); + assert.equal(zone.values[1], '100,100 300,100 300,300'); + + const empty = renderRadarLive([frame(false)], { x: 0, y: 0, w: 1000, h: 1000 }, (p) => p); + assert.equal(empty, Symbol.for('lit-nothing')); +}); diff --git a/test/radar-setup.test.mjs b/test/radar-setup.test.mjs index 97a68a2e..f36f0698 100644 --- a/test/radar-setup.test.mjs +++ b/test/radar-setup.test.mjs @@ -23,6 +23,7 @@ test('on-plan installation changes only the editor draft until ordinary Save', ( const controller = new RadarSetupController({ hass: () => ({}), requestUpdate() {}, t: (key) => key, configFromDraft: radarConfigFromDraft, apply: (next) => { applied = next; }, + confirmDiscard: async () => true, }); assert.equal(controller.begin('radar', draft(), { id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1], [0, 1]], @@ -40,17 +41,37 @@ test('on-plan installation changes only the editor draft until ordinary Save', ( assert.equal(radarConfigFromDraft(applied, 5).mount.x, .25); }); -test('cancelling calibration releases its draft subscription exactly once', () => { +test('cancelling calibration releases its draft subscription exactly once', async () => { const controller = new RadarSetupController({ hass: () => ({}), requestUpdate() {}, t: (key) => key, - configFromDraft: radarConfigFromDraft, apply() {}, + configFromDraft: radarConfigFromDraft, apply() {}, confirmDiscard: async () => true, }); assert.equal(controller.begin('radar', draft(), { id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]], }, 5, 7), true); let calls = 0; controller.unsubscribe = () => { calls += 1; }; - assert.equal(controller.cancel(), true); - assert.equal(controller.cancel(), false); + assert.equal(await controller.cancel(), true); + assert.equal(await controller.cancel(), true); assert.equal(calls, 1); }); + +test('dirty calibration stays open when discard confirmation is rejected', async () => { + let confirmations = 0; + const controller = new RadarSetupController({ + hass: () => ({}), requestUpdate() {}, t: (key) => key, + configFromDraft: radarConfigFromDraft, apply() {}, + confirmDiscard: async () => { confirmations += 1; return false; }, + }); + assert.equal(controller.begin('radar', draft(), { + id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]], + }, 5, 7), true); + controller.choosePoint(pointer(250, 400)); + let cleanups = 0; + controller.unsubscribe = () => { cleanups += 1; }; + + assert.equal(await controller.cancel(), false); + assert.equal(confirmations, 1); + assert.equal(cleanups, 0); + assert.ok(controller.active); +}); diff --git a/tests_backend/test_ha_radar.py b/tests_backend/test_ha_radar.py index a7bd0d0a..e9a32022 100644 --- a/tests_backend/test_ha_radar.py +++ b/tests_backend/test_ha_radar.py @@ -2,13 +2,18 @@ from __future__ import annotations import copy +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.houseplan.radar import RadarCoordinator +from custom_components.houseplan.radar_validation import radar_source_entity_ids def _stored(profile: str = "esphome_ld2450_v1") -> dict: @@ -35,6 +40,28 @@ def _stored(profile: str = "esphome_ld2450_v1") -> dict: async def _coordinator(hass: HomeAssistant, document: dict) -> RadarCoordinator: + radar = document["config"]["markers"][0]["radar"] + if radar.get("profile") == "esphome_ld2450_v1": + MockConfigEntry( + domain="esphome", title="Radar", entry_id="radar-test", + ).add_to_hass(hass) + device = dr.async_get(hass).async_get_or_create( + config_entry_id="radar-test", identifiers={("esphome", "radar-test")}, + name="Radar", model="HLK-LD2450", + ) + document["config"]["markers"][0]["binding"] = f"device:{device.id}" + registry = er.async_get(hass) + for entity_id in radar_source_entity_ids(radar): + domain, object_id = entity_id.split(".", 1) + state = hass.states.get(entity_id) + if state is not None: + hass.states.async_remove(entity_id) + registry.async_get_or_create( + domain, "esphome", object_id, device_id=device.id, + suggested_object_id=object_id, + ) + if state is not None: + hass.states.async_set(entity_id, state.state, state.attributes) runtime = SimpleNamespace(config_store=AsyncMock()) runtime.config_store.async_load.return_value = document coordinator = RadarCoordinator(hass, runtime) @@ -78,6 +105,30 @@ async def test_generic_cartesian_origin_is_not_treated_as_absence( coordinator.teardown() +@pytest.mark.asyncio +async def test_mixed_current_and_stale_coordinate_slots_report_partial( + hass: HomeAssistant, +) -> None: + document = _stored("cartesian_v1") + sources = document["config"]["markers"][0]["radar"]["sources"] + sources["slots"].append({ + "id": "target_2", "x_entity": "sensor.radar_target_2_x", + "y_entity": "sensor.radar_target_2_y", "unit": "mm", + }) + hass.states.async_set("binary_sensor.radar_presence", "on") + hass.states.async_set("sensor.radar_target_1_x", "0") + hass.states.async_set("sensor.radar_target_1_y", "1000") + hass.states.async_set("sensor.radar_target_2_x", "unknown") + hass.states.async_set("sensor.radar_target_2_y", "1000") + + coordinator = await _coordinator(hass, document) + frame = coordinator.frames_for_space("floor")[0] + assert len(frame["targets"]) == 1 + assert frame["complete"] is False + assert frame["health"] == "partial" + coordinator.teardown() + + @pytest.mark.asyncio async def test_room_removal_and_scale_change_suspend_projection( hass: HomeAssistant, @@ -253,6 +304,26 @@ async def test_teardown_releases_frames_and_subscriptions( assert coordinator._unsub_sources == [] +@pytest.mark.asyncio +async def test_teardown_during_config_load_cannot_resurrect_subscriptions( + hass: HomeAssistant, +) -> None: + pending = hass.loop.create_future() + runtime = SimpleNamespace(config_store=SimpleNamespace(async_load=lambda: pending)) + coordinator = RadarCoordinator(hass, runtime) + setup = hass.async_create_task(coordinator.async_setup()) + await asyncio.sleep(0) + + coordinator.teardown() + pending.set_result(_stored()) + await setup + + assert coordinator.closed is True + assert coordinator.radars == {} + assert coordinator._unsub_config is None + assert coordinator._unsub_sources == [] + + @pytest.mark.asyncio async def test_presence_only_profile_reports_without_geometry( hass: HomeAssistant, diff --git a/tests_backend/test_ha_radar_websocket.py b/tests_backend/test_ha_radar_websocket.py index c8913724..709a87ec 100644 --- a/tests_backend/test_ha_radar_websocket.py +++ b/tests_backend/test_ha_radar_websocket.py @@ -38,6 +38,9 @@ class _Connection: class _Coordinator: def __init__(self) -> None: + self.hass = SimpleNamespace(states={ + "sensor.x": SimpleNamespace(state="1"), + }) self.closed = False self.server_session_id = "session-1" self.config_rev = 7 @@ -91,7 +94,8 @@ class _Coordinator: @pytest.fixture(autouse=True) -def _clear_radar_ws_state(): +def _clear_radar_ws_state(monkeypatch): + monkeypatch.setattr(radar_ws, "radar_registry_evidence", lambda _hass: {}) radar_ws._INSPECT_CALLS.clear() radar_ws._ACTIVE_SUBSCRIPTIONS.clear() radar_ws._ACTIVE_SETUP.clear() @@ -119,13 +123,17 @@ def test_coordinator_lookup_and_read_permissions(monkeypatch, hass) -> None: assert radar_ws._coordinator(hass, connection, 1) is None assert connection.errors[-1][1] == "not_ready" + monkeypatch.setattr(radar_ws, "get_data", lambda _hass: SimpleNamespace()) + assert radar_ws._coordinator(hass, connection, 2) is None + assert connection.errors[-1][1] == "unsupported_capability" + coordinator = RadarCoordinator(hass, SimpleNamespace()) monkeypatch.setattr( radar_ws, "get_data", lambda _hass: SimpleNamespace(radar_coordinator=coordinator), ) - assert radar_ws._coordinator(hass, connection, 2) is coordinator + assert radar_ws._coordinator(hass, connection, 3) is coordinator coordinator.closed = True - assert radar_ws._coordinator(hass, connection, 3) is None + assert radar_ws._coordinator(hass, connection, 4) is None assert radar_ws._can_read(connection, {"sensor.x"}) is True connection.user.permissions.allowed = False assert radar_ws._can_read(connection, {"sensor.x"}) is False @@ -194,6 +202,24 @@ def test_draft_validation_size_permission_and_success(monkeypatch) -> None: assert connection.errors[-1][1] == "invalid_selection" +def test_draft_validation_reports_unavailable_sources(monkeypatch) -> None: + coordinator = _Coordinator() + coordinator.hass.states = {} + connection = _Connection() + marker = {"id": "radar"} + radar = {"profile": "cartesian_v1"} + monkeypatch.setattr( + radar_ws, "validate_radar_draft", lambda *_args: (marker, {"sensor.x"}), + ) + + assert radar_ws._validated_draft( + coordinator, + {"id": 1, "marker_id": "radar", "draft_sources": {"radar": radar}}, + connection, + ) is None + assert connection.errors[-1][1] == "source_unavailable" + + def test_draft_validation_reports_stable_radar_error(monkeypatch) -> None: coordinator = _Coordinator() connection = _Connection() diff --git a/tests_backend/test_radar_validation.py b/tests_backend/test_radar_validation.py index 76b321d1..7d2eca23 100644 --- a/tests_backend/test_radar_validation.py +++ b/tests_backend/test_radar_validation.py @@ -32,6 +32,20 @@ def _config(): } +def _ld2450_registry(*, y_device: str = "abc", model: str = "HLK-LD2450"): + return { + "entities": { + "sensor.x": {"device_id": "abc", "platform": "esphome"}, + "sensor.y": {"device_id": y_device, "platform": "esphome"}, + "binary_sensor.presence": {"device_id": "abc", "platform": "esphome"}, + }, + "devices": { + "abc": {"model": model}, + "other": {"model": model}, + }, + } + + def test_valid_stage1_radar_and_absent_namespace(): validate_marker_radars(_config(), validate_all=True) validate_marker_radars({"spaces": [], "markers": [], "settings": {}}, validate_all=True) @@ -52,6 +66,28 @@ def test_ld2450_requires_mm_and_distinct_axis_entities(): validate_marker_radars(config, validate_all=True) +def test_ld2450_verified_adapter_requires_same_esphome_marker_device(): + config = _config() + radar = config["markers"][0]["radar"] + radar["profile"] = "esphome_ld2450_v1" + validate_marker_radars( + config, validate_all=True, registry=_ld2450_registry(), + ) + with pytest.raises(vol.Invalid, match="same ESPHome device"): + validate_marker_radars( + config, validate_all=True, registry=_ld2450_registry(y_device="other"), + ) + with pytest.raises(vol.Invalid, match="metadata"): + validate_marker_radars( + config, validate_all=True, registry=_ld2450_registry(model="Not a radar"), + ) + config["markers"][0]["binding"] = "device:other" + with pytest.raises(vol.Invalid, match="marker device"): + validate_marker_radars( + config, validate_all=True, registry=_ld2450_registry(), + ) + + def test_two_point_fit_and_owner_scale_are_revalidated_on_write(): config = _config() radar = config["markers"][0]["radar"] @@ -101,14 +137,15 @@ def test_untouched_future_radar_round_trips_but_changed_one_fails(): validate_marker_radars(changed, previous) -def test_stage2_zone_polygon_and_stage3_cross_space_group_fail_closed(): +def test_future_stage_extensions_are_preserved_but_inert_in_stage1(): config = _config() config["markers"][0]["radar"]["zones"] = {"local": [{ "id": "sofa", "name": "Sofa", "poly": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 2, "y": 0}], "state": {"kind": "targets"}, }]} - with pytest.raises(vol.Invalid, match="zero area"): - validate_marker_radars(config, validate_all=True) + config["settings"]["radar"]["fusion_groups"] = [{"future": True}] + validate_marker_radars(config, validate_all=True) + assert config["markers"][0]["radar"]["zones"]["local"][0]["id"] == "sofa" @pytest.mark.parametrize("profile", [ @@ -143,7 +180,7 @@ def test_all_stage1_profiles_have_a_valid_explicit_source_contract(profile): validate_marker_radars(config, validate_all=True) -def test_stage2_metadata_and_source_inventory_are_validated_together(): +def test_stage1_source_inventory_ignores_future_stage_extensions(): config = _config() radar = config["markers"][0]["radar"] radar["sources"].update({ @@ -179,13 +216,15 @@ def test_stage2_metadata_and_source_inventory_are_validated_together(): radar["allowed_room_ids"] = ["living"] validate_marker_radars(config, validate_all=True) ids = radar_source_entity_ids(radar) - assert {"sensor.x", "sensor.y", "binary_sensor.desk", "select.zone_mode", - "number.zone_3_y2"} <= ids + assert ids == { + "sensor.x", "sensor.y", "binary_sensor.presence", "sensor.count", + "binary_sensor.available", "binary_sensor.slot", + } assert radar_source_entity_ids(None) == set() assert radar_source_entity_ids({"sources": []}) == set() -def test_valid_fusion_and_room_output_settings(): +def test_future_fusion_and_output_settings_round_trip_without_stage1_validation(): config = _config() second = copy.deepcopy(config["markers"][0]) second["id"] = "radar-two" diff --git a/tsconfig.test.json b/tsconfig.test.json index d22b3b4f..e5fd4185 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -14,7 +14,7 @@ "src/device-face.ts", "src/device-toggle.ts", "src/marker-toggle-entity.ts", "src/activity-runtime.ts", "src/device-area-relocation.ts", "src/ha-binding-status.ts", "src/zigbee-topology-settings.ts", "src/zigbee-topology.ts", "src/zigbee-topology-geometry.ts", "src/zigbee-topology-runtime.ts", - "src/radar-model.ts", "src/radar-editor.ts", "src/radar-geometry.ts", "src/radar-setup.ts", + "src/radar-model.ts", "src/radar-editor.ts", "src/radar-geometry.ts", "src/radar-setup.ts", "src/radar-render.ts", "src/integration-provider.ts", "src/vacuum.ts", "src/vacuum-routes.ts", "src/vacuum-route-edit.ts", "src/sun.ts", "src/light-visibility.ts", "src/glow-scene.ts", "src/space-render.ts",