Files
houseplan-card/docs/specs/485-radar-presence-stage1.md
T
2026-09-09 03:54:09 +03:00

52 KiB
Raw Blame History

#485 — Stage 1: sources, calibration and live presence

Issue: #485. Read the common contract first. The contract completed independent review at spec-r4; the owner authorized S6 on 2026-09-08 and this document is the implementation authority for Stage 1. Stages 2 and 3 remain separate, unimplemented scopes.

1. Before / after, scope and integration seams

Before, an administrator can place the ordinary radar device but cannot see its coordinate observations on the plan. They read separate HA sensors/spreadsheets. After, they bind available sources, identify the physical installation, calibrate and see current observations; household members need no editor or hover action. Presence, position and source availability are separate facts.

Included: explicit source/capability model; safe discovery/manual mapping; physical mount, orientation, mirror and known units; two-position calibration and manual alternative; live dots/arcs/read-only known zones/presence; one-room clipping; short diagnostic trail; health, validation, permissions and lifecycle. Excluded: raw recording, zone writes/drawing, reflectors, multi-room fusion, heat maps and derived HA entities (specified in stages 2–3). No stable person identification.

Existing integration points, inspected on the common baseline:

Existing file/module Required change
src/types.ts, src/config-store.ts Optional marker/settings fields and revisioned save; no local authoritative parallel config
src/houseplan-editor-runtime.ts Add section to existing marker dialog; delegate lazy setup instead of growing monolith
src/editor-secondary.ts, src/editors/ Reuse context surface for selected radar setup and safe exit
src/houseplan-card.ts, render-device-snapshot.ts, houseplan-render-lifecycle.ts Subscribe selected space; render normalized live snapshots without reparsing HA values per marker
src/space-geometry.ts, src/live-viewport.ts Canonical square normalized/render conversion and existing projection/viewport seams
src/styles/{devices,dialogs,chrome}.styles.ts, i18n dictionaries Quiet live visuals, setup layout and en/ru text
custom_components/houseplan/{__init__,store}.py Coordinator setup/reconcile/unload ownership
custom_components/houseplan/{validation,websocket_api,auth}.py Change-aware validation, capabilities, authenticated commands and one may_write policy
scripts/config-schema.json, scripts/config-field-registry.mjs Generated schema parity and explicit compatibility classification

Proposed pure/client modules: src/radar-model.ts, radar-geometry.ts, radar-live.ts, radar-render.ts, src/editors/radar-setup.ts. Backend: radar.py (source coordinator), radar_geometry.py, radar_validation.py, radar_websocket.py. Subsequent stages extend these contracts, not a second source pipeline. Existing vacuum calibration/trails are precedents, not shared mutable state. Names may change in review without changing the contracts.

2. Entry, defaults and ordinary use

Device editor → select an eligible device or placed-entity marker → Presence on plan. Owner clarification of 2026-09-08: show this section only for suitable devices, not every device in the editor. Without eligibility there is no main section header, placeholder, disabled switch, unsupported teaser or primary radar setup button. Q5's accepted secondary manual-entry action below is the deliberate route for unknown/custom hardware, not an automatic section. The global display preference below remains unchanged.

Section eligibility is distinct from live availability and from successful automatic source assignment:

  • A positive presence-radar device/profile match or a verified radar-specific source-role descriptor on the exact owning device makes it eligible. Match evidence is structural adapter/registry metadata, not a friendly-name search. It must support at least one of the four data classes in §3; no coordinate capability is required for a supported range/zone/presence-only radar.
  • Missing/ambiguous axis bindings or unavailable/disabled source entities do not hide a positively identified radar: it still offers manual source assignment/repair under §3. Eligibility does not require a live on state, currently detected targets or a successful calibration.
  • An already saved marker.radar configuration keeps its section accessible for repair, disabling or removal when sources/metadata become unavailable or the saved version is unsupported. Unsupported/malformed saved content remains inert and preserved under the existing compatibility rules; showing repair grants no runtime or device-write capability.
  • The user's explicit This is a presence radar action admits an otherwise unrecognized real device or placed-entity marker to manual setup for the current draft session. No positive manufacturer/profile descriptor or owning HA device_id is required for this route. It neither preselects numeric sources nor grants live, calibration or hardware-write capabilities.
  • A normal light, switch, temperature/humidity sensor, ordinary PIR, virtual marker, or a device with merely arbitrary numeric/binary entities is not a radar candidate automatically. Two numeric values or a generic occupancy/motion class alone do not prove a radar profile. A virtual marker with inherited invalid radar content may show cleanup-only repair, never enable live setup.
  • Unresolved metadata with no saved radar configuration is not positive evidence: do not flash a generic section while loading. The secondary manual-entry action remains available without that metadata. Reevaluate the same exact device when registry data becomes available; never retarget to another device in order to make the section appear.

Use one derived eligibility result for the section entry and its child tools: positive recognition, existing saved repair, or explicit session-local manual entry. Do not persist a new display flag or run source subscriptions merely to decide whether a header is visible. The resolver's concrete metadata signatures are engineering choices with positive and negative fixtures, not a UI taxonomy.

Manual entry (Q5). In the marker editor's collapsed Additional actions group at the end of its body, offer This is a presence radar for a real device/placed-entity binding that has neither positive recognition nor a saved radar block. The group/action is secondary, keyboard/touch operable and governed by normal editor write access; virtual markers never get it. This explicit escape hatch is available even if registry identity is unresolved, including a standalone sensor with no device_id. Ordinary devices still have no main radar section unless the administrator deliberately takes this action.

The action opens the same setup wizard at manual profile/source selection, with no auto-picked profile/entities and no extra confirmation dialog. The user chooses one of the generic profiles in §3 and exact sources, then follows the existing setup/Save flow. Persist only the complete normal marker.radar block on successful atomic Save, not an is_radar flag or a provisional block. Cancel/Esc, leaving setup, closing the editor or page hide discards this session-local declaration with the draft and releases any setup listeners. If there was no eligibility beforehand, the main section stays hidden on return or reopening. Saving makes subsequent access use the saved-configuration path, including while disabled/offline. Removing that saved configuration restores automatic-only eligibility and the secondary entry if still unrecognized. Changing the marker binding clears an unsaved manual declaration; it must not silently transfer consent or draft sources to another device/virtual marker.

Resolve an entity: marker's owning device through the existing registry snapshot's entity device_id and src/ha-binding-status.ts authority. Inspect full available registry metadata, not only the enabled-state dev.entities projection: filtering out disabled diagnostic entities must not hide a known radar. Proposed radar-model.ts owns the pure section predicate; the existing conditional vacuum section in houseplan-editor-runtime.ts is a UI integration precedent, not radar detection logic. Completely unidentifiable hardware cannot be automatically distinguished from an ordinary PIR; Q5's explicit manual entry resolves this without making the section universal. An entity without an owning device cannot auto-discover sibling sources but remains eligible for explicit generic mapping. This does not remove manual role assignment from an identified radar.

For eligible, not-yet-configured radars the section is collapsed/off. No automatic enabling on upgrade, placement or source discovery. A virtual marker cannot own a live radar binding. Source entities need not be separately placed on the plan.

The first section has: configure switch; connection/profile; capability summary; selected room; Configure on plan; Show live presence. On an eligible radar or its saved repair path, unsupported/missing data gets a short explanation and manual source selection, not dozens of disabled advanced controls. Advanced displays exact source ids, values, units and report ages so an administrator can correct a connection without editing YAML.

General Settings → Display adds Show presence on the plan, default true. It remains present even when no eligible radar is discovered/configured: the owner requested conditional visibility only for the device-editor section. Both global and per-radar show_live must be true, and the radar must be enabled and valid. Hiding this layer does not alter HA presence, calibration, or later recording consent. The global switch hides all live radar layers for shared configuration; an unsaved diagnostic overlay remains local to its setup session.

Ordinary View shows configured current marks automatically. No diagnostic trails, sensor sectors, raw counts, point labels or setup handles. Device actions retain the selected tap_action; no extra action is forced onto the icon. Radar health appears in the existing device information surface when opened by the supported action/hold path, and in setup. Dot overlays never become independent devices. Presence-only sources keep the existing marker state; do not add a floor fill which competes with existing light effects. Known occupied zones use a thin outline/very low-opacity presence overlay distinct from light Glow.

3. Source resolution and capabilities

Persist exact entity ids only. Discovery can propose candidates from the owning HA device and a verified adapter signature; accepting the summary is explicit. Do not infer axes/units from translated friendly names, or select an unrelated global entity with a similar name. A placed entity resolves its current registry device for discovery; on an eligible radar or existing saved repair path, missing/limited role metadata offers manual selection without claiming deleted or disabled. With neither positive radar evidence nor saved configuration, §2 keeps the main section hidden until explicit manual entry. Existing binding-status authority remains in use.

Manual generic mapping does not require an owning HA device, known firmware or radar-specific device_class. It accepts exact, explicitly selected sensor.* and binary_sensor.* roles required by the chosen profile, even when their device_id is absent or differs. List only sources readable by the current user; present their exact ids and device attribution (when known), with no cross-device auto-binding or fuzzy-name fallback. Numeric profiles still require their complete unit/axis/range conventions and finite values per this section. For example, a custom numeric distance sensor may use range_v1 without an occupancy source or manufacturer signature; it yields an arc, not an invented target point. Marker/space ownership and per-source read ACL remain mandatory. Manual declaration never supplies a verified hardware adapter signature.

For LD2450 automatic role mapping, require the same ESPHome device, recognized LD2450 descriptor and a complete unique role set exposed by registry metadata. If metadata cannot prove a unique x/y pairing/slot, preselect nothing: show the candidate list grouped by that device for manual confirmation. No entity registry enablement, ESPHome reflash, HA restart or force_update edit occurs here.

Profiles and capability inputs:

Profile Required sources / conventions Output
esphome_ld2450_v1 1–3 explicitly matched X/Y slots, units mm; canonical x right, y forward; optional per-slot presence/speed and global occupancy/count Cartesian targets; optional known hardware-zone read capability
cartesian_v1 1–8 slots of exact numeric x/y sources, declared unit and axis mapping; optional gates Cartesian targets
polar_v1 1–8 slots with distance and bearing, declared length/angle units and angle origin/direction Convert to canonical Cartesian targets; no inferred bearing
range_v1 One or two named distance channels, explicit length unit, optional occupancy gate Arc(s), not two people; direction/FOV must be known or explicitly entered
zones_v1 Existing exact occupancy/count entities; known adapter geometry if available Read-only zone outlines; unknown geometry awaits Stage-2 manual mapping
presence_v1 Exact binary_sensor occupancy, optional known diagnostic range/FOV Presence state only; no position or count

Known units mm/cm/m/in/ft convert by exact factors 0.1/1/100/2.54/30.48 to cm. Absent/unrecognized units require explicit user confirmation of the declared unit; conflicting present units are rejected with the source identified. Never guess from values. Cartesian axis mapping is a permutation of x/y and signs ±1, no arbitrary formula. Polar input declares radians/degrees, zero-forward or zero-right and clockwise/counterclockwise; distance is nonnegative. Source strings unknown, unavailable, empty, NaN/Infinity are not numeric zero. Cartesian x=0, negative x and valid origin observations remain legal.

The reference ESPHome LD2450 source was clarified with a real 68k-row capture in the issue on 2026-09-08: signed X is left/right, positive Y is forward, and that adapter uses the simultaneously reported pair X=0,Y=0 for an unused target slot. Treat only that complete fresh verified-adapter pair as explicit absence. One zero axis remains a valid position, and generic Cartesian sources continue to allow a real origin unless their own verified profile defines an absence signal. The contributed capture informs synthetic boundary fixtures; it is not copied into the repository without an explicit data licence.

Global occupancy off suppresses all targets immediately; unknown/unavailable gate suppresses gated geometry without becoming off. Per-slot gates affect their own slots. on allows coordinates but never manufactures them. Optional count is a consistency/diagnostic input, not a stable assignment of slot numbers or permission to keep unknown slots visible. A contradictory fresh zero count with valid coordinates is inconsistent, not a silently chosen truth.

Backend capabilities are runtime facts: coordinates, range, zone_state, known_zone_geometry, reported_presence, coherent_frames, hardware_zones. Stage-1 hardware zones are read-only and require the complete same-device descriptor later specified in Stage 2; optimistic HA values are marked as HA reported geometry, not device-verified configuration. FP2 branding alone never grants coordinates or hardware writes.

4. Measurement health, pairing and delivery

Source freshness is HA report freshness, not proof that a UART/radio produced a new physical measurement. HA 2024.6.0 (the current minimum) already contains state_reported and State.last_reported. Subscribe with an event filter to the exact configured entity ids; never listen to all state reports or use last_changed as sample time. The backend timestamp is authoritative.

For independent numeric entities, pair_quality:'bounded_latest' means both values were reported within 3 seconds and their report times differ by <=1.5 seconds. Coalesce reports for 100 ms before publishing a pair. This tolerates independent ~1 Hz channels but does not claim an atomic sensor frame. If a future verified adapter exposes a common sample sequence/timestamp in its defined payload, use it to establish coherent; generic arbitrary attribute expressions are not part of this implementation. Stages 2–3 may not upgrade bounded_latest to coherent merely because values are numerically close.

The standard LD2450 connection suppresses repeated coordinate values and its timeout filter repeats the last value only once. A stationary target, or a target with an unchanged axis, can therefore lose usable current coordinates. After either axis expires, hide its dot and show Presence reported; current position unavailable if occupancy remains on. Setup explains this telemetry limitation and shows which axis has no recent report; no silent firmware edits or guessed movement repair it. A profile that legitimately republishes values may keep them visible while fresh: UI calls these reported coordinates, never guaranteed physical freshness. An integration that continuously republishes incorrect data cannot be detected from HA state alone.

Range channels use the same <=3 s report lease. Binary presence/zone entities use HA's current on/off/unknown/unavailable contract, not the coordinate lease: a still person legitimately produces no binary transitions for a long time. Their last change is labelled as a state change, not “last measured”. Losing a bound entity or configured availability gate yields unknown/unavailable. Never equate HA node connectivity with proof of fresh radar coordinates.

On coordinator startup/reconnect, initial values keep their actual report ages; loading a snapshot does not renew them. After a failed/incomplete source epoch, require a complete eligible pair before showing a dot again. Invalid slot coordinates clear that slot immediately, even during a presence hold timeout. No guessed zero-target frame from an empty list of expired slots.

For LD2450, unknown X/Y by itself is not explicit absence. A complete fresh X=0,Y=0 pair is explicit absence under the verified ESPHome adapter contract documented above; a lone zero or stale/partial pair is not. Global occupancy off is explicit negative evidence under that source's current HA state semantics; an eligible fresh global target count of zero with no conflicting valid target is also explicit negative. Otherwise an unknown slot keeps coordinate completeness false unless the verified profile has an explicit per-slot absence signal. Do not silently deduce missing slots from a nonzero count or assume that target indices are packed. Generic profiles use exact per-slot/global gates for the same purpose. A complete negative produces an empty frame, not an error.

Normalized frame contains server_session_id,marker_id,source_generation, calibration_revision,seq,reported_at,expires_at,health,reported_presence, complete,targets[],ranges[],zones[]. A target includes slot, canonical cm x,y, report times, pair_quality, projected plan position, included and exclusion reason. complete means every configured slot is either a valid current observation or explicitly absent in a supported source report; stale missing data makes the frame incomplete. Source slot ids are ephemeral labels.

Live subscription is authenticated, selected by exact space id, maximum 32 radars/space response, <=256 targets total. Read-only View receives only allowed live projected output/health, no raw coordinates, source ids or historical buffers. Require HA read permission for every bound entity contributing to a delivered radar; otherwise withhold that radar's values as restricted. Setup detail requires may_write plus the same source read checks. Revoked permissions are rechecked on publish and at most each minute even without source events; clear the affected connection's output. Do not publish coordinates on broad HA events. Use connection-scoped WS delivery.

Publish at most 4 geometry frames/s/radar, latest-wins within a bounded slot; health/clear transitions are immediate and cannot be dropped by rate limiting. Maximum one active subscription per card instance; server deduplicates source listeners. On space/mode change, page hidden, disconnect or unmount, unsubscribe and clear local arrays/trails; resume uses a new snapshot, never interpolation from the previous session. Browser uses remaining lease measured monotonically from receipt, reduced by transport age; expiration hides output even if WS dies without a clean close. Invalid or late session/sequence/revision messages are discarded. A slow consumer receives latest state, not an unbounded frame queue.

5. Geometry and calibration

5.1 Canonical space

Use actual current src/space-geometry.ts, not the legacy bitmap coordinate section of ARCHITECTURE: persisted coordinates are square normalized units; render space is NORM_W=1000 square; GRID_N=240; GRID_PITCH=1000/240. One normalized unit represents 240 * cell_cm physical centimetres. Stored spaces without cell_cm retain the existing 5 cm compatibility default; new space defaults are not changed by this issue.

Physical mount m=(mx,my) is stored in normalized plan units independently of the marker's decorative layout position. For local centimetres (x,y) with x right and y forward, heading θ clockwise from plan-up and mirror s=-1 or nonmirror s=1, projection is:

p = m + (s*x*cosθ + y*sinθ, s*x*sinθ - y*cosθ) / (240*cell_cm).

Inverse uses the transpose orthogonal matrix and the same known scale. No independent x/y stretch, fit-to-room rectangle, north/compass substitution or wallpaper transform. Plan up is not geographic north. Validate finite results within CANVAS_LIMIT=5000 normalized; a physically impossible numerical input (absolute local distance >100 m) is invalid rather than clamped onto the plan. Shared TS/Python fixtures cover ±x, y=0, headings 0/90/180/270, mirror, imperial conversion, non-origin mount and 1/5 cm spaces representing the same room.

5.2 Wizard, no premature persistence

Touch editor: best effort / intentionally degraded. The calibration wizard, including mount/reference placement, uses the desktop Device editor as its reference environment. This is deliberate, not a missing mobile implementation promise: mark the reference on the computer first, start the 10-second countdown, walk to the marked physical position without carrying the computer, remain still during capture, then return to inspect the result. The existing countdown makes that single-person desktop workflow possible; a phone/second controller is not required. If the user cannot complete the timed exercise comfortably, Retry or manual direction/mirror setup remains available rather than accepting a bad fit.

A phone/tablet may run the same wizard best-effort, but precise point placement and full mobile parity are not promised. Show the desktop recommendation before setup and describe the limitation in both guides and release notes. Cancel/Back, lost permission, page hide, pinch, a second pointer or pointercancel must never commit a reference, complete a capture or save configuration accidentally. Page hide interrupts capture and clears transient samples; returning offers Retry, not a fabricated successful measurement. Essential safe exit/confirmation controls remain reachable. View/kiosk retain full touch support; S1-10/S1-18 and touch safety smoke cover this floor, not unsupported precision parity.

Step 1: Installation. Select the existing room; place the real sensor mount on the plan and drag its direction arrow. If decorative marker placement is available, offer it as a starting suggestion only, with text distinguishing it from physical installation. Show known/entered sector as an approximate guide; unknown FOV stays absent. The room must belong to the exact marker's owner space. A room without contour warns “No room boundary: points are not clipped”.

Step 2: Two reference positions. Place reference A on the plan → Start measurement → 10-second countdown to walk there → 5-second capture while alone and still. Collect at least three eligible coordinate pairs spanning >=2 s, with exactly one valid target throughout capture and no competing slot/gate failure. Median X/Y is the sample; reject if any sample is >15 cm from the median. Then do B. Missing/insufficient/ambiguous data offers Retry, never an arbitrary slot selection. All measurements remain browser memory only.

Mount and units are known. Each reference must be >=50 cm from mount; vectors to A and B must form an angle between 20° and 160° (nearly collinear vectors cannot determine mirror). For both mirror hypotheses, solve the best rigid rotation about the mount by least squares using both vectors, with unit scale fixed. Require each radial-distance mismatch <=max(20 cm,15% of reference distance), RMS projected error <=20 cm and individual error <=30 cm. If both mirror candidates pass with RMS difference <10 cm, report ambiguous rather than choose. These are explicit engineering tolerances, not accuracy promises.

Manual alternative exposes direction and mirror with live preview; it does not fit fake translation/scale or mark an unmeasured setup as auto-verified. Range-only requires mount/direction/known FOV, not a two-point position fit; zone/presence-only skips coordinate calibration. If range FOV is unknown, retain numeric range in setup and presence only in View until supplied.

Step 3: Check. Show live output and optional local 8-second diagnostic trail; offer a third independent marked position and display observed distance error without adjusting the fitted parameters. Failed data remains an explicit error. The third check is recommended, not a hidden Save prerequisite. Summary states auto-fit/manual, room, source class, unit and whether clipping is available. Save commits one revisioned radar block after server validation. Cancel/Esc, source/space removal, leaving the editor or lost permission disposes the draft, restores the previous view and makes no config/service write. Closing a dirty wizard asks to discard; losing a source does not silently save partial setup.

Two clients: Save with stale expected_rev returns conflict; keep draft for inspection, offer Reload, do not overwrite or rebase physical calibration silently. Changing source/unit/axis/installation during a draft resets captures. An incoming accepted change to that radar invalidates the draft save token.

5.3 Live membership and rendering

If selected room has a valid real polygon, use its existing physical room boundary authority, including concavity. Do not derive a rectangle from label coordinates. Points inside/on the boundary (physical tolerance 0.1 cm) remain; outside-room observations are excluded from display, not diagnosed as ghosts. Missing/invalid/deleted room ref suspends the radar; existing room with no usable contour allows unclipped calibrated output with the stated warning.

Membership uses raw projected points before interpolation. Arcs are clipped geometrically to the allowed polygon, not tested solely at their midpoint; known zone geometry intersects that polygon. If clipping removes all geometry, do not replace it with a point on the wall or mark the room empty on this basis.

Dots: 8 CSS px diameter, contrast rim 2 px and theme-aware presence accent; never depend on color alone. Render within a dedicated pointer-transparent floor-level overlay using existing pan/zoom/isometry transforms, compensating screen radius to avoid enormous dots at high zoom. Multiple independent radar observations may overlap; do not claim one-to-one people correspondence. Arcs use 2 CSS px stroke and explicit range uncertainty in the radar info surface. Known occupied zones use 2 px outline and <=0.08 fill opacity; unknown state does not show occupied fill. Ordinary presence-only markers retain existing visuals. No per-frame spoken announcements.

Visual smoothing lasts <=250 ms, no extrapolation, follows valid included endpoints from one slot/session/generation only. Disable for reduced motion, gaps, availability loss, room/filter changes or jumps >1 m; snap to the new observation instead of drawing a continuous travel claim. Never interpolate through an excluded area: clip the rendered segment/output to allowed geometry. Slots are not identities. Diagnostic trail retains at most 8 seconds/32 samples per slot, is segmented on the same gaps, and is deleted on setup exit/page hide. No Stage-1 persistent history or server raw trail exists.

6. Saved fields, validity and lifecycle

Path Type / semantics
settings.radar Optional object; show_live?:boolean, absent true; Stage-3 extensions preserved
marker.radar Optional object; version:1, enabled:boolean, show_live?:boolean absent true; maximum 32 configured blocks per integration
radar.profile One of the six profile identifiers in §3
radar.sources {slots?:Slot[],ranges?:Range[],zones?:ZoneSource[],occupancy_entity?:string,count_entity?:string,availability_entity?:string}; only applicable subsets accepted
Slot {id,x_entity,y_entity,unit,swap_xy?,x_sign?,y_sign?,presence_entity?} for Cartesian; polar instead {id,distance_entity,angle_entity,unit,angle_unit,angle_zero,angle_clockwise,presence_entity?}; max8, LD2450 max3; distinct bounded slot ids
Range {id,entity_id,unit,presence_entity?}; max2; moving/still labels are descriptive, not identities
ZoneSource {id,kind:'occupancy'|'count',entity_id}; max32; Stage1 geometry only from verified adapter descriptor
radar.mount {installation_id,x,y,heading_deg,range_cm?,fov_deg?} installation UUID, normalized finite coordinates, heading [0,360), optional range (0,10000] cm/FOV (0,360]; physical install, not marker layout
radar.room_id Required exact existing room id in owner space; may be a room without a contour; no nearest-room fallback
radar.calibration {method:'manual'|'two_point'|'not_required',mirror:boolean,cell_cm:number,refs?:[{plan:{x,y},local_cm:{x,y}}],rms_cm?:number}; two_point exactly2 refs and valid §5 fit; no timestamps or raw captures

Coordinate/length sources are exact sensor.*; gates binary_sensor.*; count integer >=0; supported bools strict. Existing entity-id maximum255 applies; slot/zone ids 1–64 [A-Za-z0-9_-]; no wildcards/templates/JS/attribute expressions. Expected source/entity data can be missing at runtime without deleting config; Save requires structurally valid mappings and confirmed units, not perpetual network availability. Invalid payload/new unknown version is rejected; untouched future version remains lossless/inert on unrelated writes.

Known-only field validation does not strip Stage-2/3 extensions or unknown siblings. Geometry follows existing normalized coordinate envelope and 2 MiB total config ceiling. Source generations are computed server-side from accepted configuration; a client cannot fake generation to reuse stale setup/history. Stored calibration's cell_cm detects a changed physical scale. Source schema permits missing calibration only when disabled; enabling requires a valid method.

The initial setup and explicit Change installation generate a fresh UUID in the saved mount. That UUID, mount x/y, bindings/conventions and owner space form the server generation fingerprint; heading/mirror/reference corrections alone form a calibration revision. A client-supplied UUID is only a change marker, never an authorization token or a way to preserve generation across changed physical coordinates. Moving a physical sensor away and back must use Change installation even when final x/y happen to be identical. There is no automatic way to detect an unreported physical move from HA coordinates.

Moving the marker's decorative icon is unrelated to mount. Explicit Change installation enters setup, suspends accepted projection only upon Save and creates a new source generation; Cancel keeps old calibration. Changing the owner space through existing marker relocation preserves original block inert as needing setup, never projects it onto another floor. Room reassignment is explicit and rechecks membership without silently moving physical mount.

Space import/duplicate/removal and full/plan-only exports follow the common contract. Extend _space_marker_dependencies and copy/remap/cleanup authorities to include radar owner/room refs. Shared wall optimization that preserves room coordinate geometry does not reset calibration. Geometry edits refresh clipping; room deletion stops output, preserved invalid ref requires repair. Physical scale change suspends projection until calibration is acknowledged anew. Empty spaces do not create dummy models or surviving subscriptions. Rename entity: follow existing positive registry-id evidence only if its binding resolver already supports it; never invent cross-name fallback. Otherwise show repair.

7. WebSocket contract and authorization

config/get advertises radar_stage1_api:1 only after coordinator setup. Absent capability disables radar operations with an update message; fields still survive. Use exact space/marker ids, not arbitrary source arrays in ordinary subscribe.

Command Request Authority/result
houseplan/radar/subscribe {space_id} Authenticated live subscription; per-source read permission; safe projected frames + health only
houseplan/radar/setup/inspect {marker_id,draft_sources?} may_write + source read permission; bounded capability/source-value snapshot; no persistence/services
houseplan/radar/setup/subscribe {marker_id,draft_sources?,expected_config_rev} Same guards; ephemeral setup values, <=1 draft subscription/user/marker, <=8 globally

Draft sources use the same strict schema/size caps and owner checks: the existing marker belongs to the requested space/configuration and is writable under may_write; every explicitly selected source is independently readable. Generic profiles do not require shared/non-null HA device_id. Same-device checks remain mandatory for verified hardware adapter roles, not generic input. Neither manual-entry consent nor frontend eligibility is authorization; the server validates the draft profile/source graph itself. Setup does not gain permission to query arbitrary HA state using an unvalidated attribute path. Server rejects disabled/removed markers, stale revisions or invalid source graph before allocating listeners. Subscription ids use HA's normal unsubscribe protocol; no custom unauthenticated stream endpoint. Saving is ordinary houseplan/config/set with expected_rev, validated via the same source/geometry authority. Stage1 has no device-control command.

New stable errors: invalid_radar, unsupported_capability, not_ready, source_unavailable, source_restricted, invalid_selection, conflict, rate_limited. Each maps to localized text; no raw exception/source payload in UI/logs. Inspect <=10requests/min/user; subscribe max4 card streams/user plus the separate bounded setup stream; response <=256KiB. Limits do not extend sample leases. Teardown checks after awaits prevent resurrection on unload.

8. i18n EN/RU

Keys prefixed radar. in the existing dictionaries; reuse common Save/Cancel/ Retry/Close and device/room/entity selection keys. Existing locale formatters handle values; every new dynamic error is mapped to a translated key.

Key English Русский
title Presence on plan Присутствие на плане
additional_actions Additional actions Дополнительные действия
declare_radar This is a presence radar Это радар присутствия
show_global Show presence on the plan Показывать присутствие на плане
enable Configure presence on plan Настроить присутствие на плане
show_live Show live presence Показывать текущее присутствие
configure Configure on plan Настроить на плане
profile Connection type Тип подключения
sources Data sources Источники данных
manual_sources Select sources manually Выбрать источники вручную
ambiguous_sources Check the source assignments Проверьте назначение источников
unsupported This connection has no supported position data Подключение не передаёт поддерживаемые данные о положении
room Observed room Наблюдаемая комната
mount Physical sensor position Физическое положение датчика
mount_hint Moving the device icon does not move this installation Перемещение иконки не меняет физическую установку
change_installation Change installation Изменить установку
heading Direction from plan up Направление относительно верха плана
mirror Mirror the horizontal axis Отразить горизонтальную ось
unit Coordinate unit Единица координат
unit_required Confirm the source unit before continuing Подтвердите единицу источника для продолжения
unit_conflict The source unit does not match the selected unit Единица источника не совпадает с выбранной
step_install Installation Установка
step_measure Reference positions Контрольные положения
step_check Check and save Проверить и сохранить
mark_reference Mark reference {point} on the plan Укажите положение {point} на плане
start_measure Start measurement Начать измерение
countdown Stand at the mark in {seconds} seconds Встаньте в отмеченное место через {seconds} с
stay_still Stand still and alone while measuring Во время измерения стойте неподвижно и в одиночестве
insufficient_samples Not enough current coordinates. Retry or use manual setup. Недостаточно актуальных координат. Повторите или настройте вручную.
ambiguous_target More than one target: repeat alone Несколько целей: повторите измерение в одиночестве
bad_references Choose separated references in different directions from the sensor Выберите разнесённые положения в разных направлениях от датчика
bad_fit Measurements do not match. Check units, mount and references. Измерения не совпадают. Проверьте единицы, установку и контрольные положения.
manual_calibration Set direction and mirror manually Задать направление и отражение вручную
check_third Check at another position Проверить в другом положении
error_distance Position difference: {distance} Расхождение положения: {distance}
discard_setup Discard the unsaved radar setup? Отменить несохранённую настройку радара?
no_contour No room boundary: points are not clipped Нет контура комнаты: точки не ограничены её границами
needs_setup Installation needs setup Требуется настройка установки
live Current reported positions Текущие положения по данным датчика
clear No targets detected Цели не обнаружены
position_missing Presence reported; current position unavailable Датчик сообщает присутствие; актуальное положение недоступно
incomplete Some position data is unavailable Часть данных о положении недоступна
offline Sensor data unavailable Данные датчика недоступны
restricted No access to the selected sources Нет доступа к выбранным источникам
inconsistent The source reports conflicting values Источник передаёт противоречивые значения
report_age Last report: {age} ago Последнее сообщение: {age} назад
report_only Report time does not verify a new physical measurement Время сообщения не подтверждает новое физическое измерение
unchanged_axis This source may stop reporting an unchanged coordinate; presence can remain available without a position Источник может не повторять неизменившуюся координату; присутствие может быть доступно без положения
range_uncertain Distance is known; direction within the arc is unknown Дальность известна; направление внутри дуги неизвестно
coverage_estimate Approximate coverage, not a detection guarantee Примерная область обзора, не гарантия обнаружения
trail Show the last 8 seconds while checking Показывать последние 8 секунд при проверке
update_backend Update the House Plan integration to use presence on plan Обновите интеграцию House Plan для присутствия на плане
desktop_hint Use a computer for precise setup Для точной настройки используйте компьютер
profile_cartesian Coordinates X/Y Координаты X/Y
profile_polar Distance and bearing Дальность и угол
profile_range Distance only Только дальность
profile_zones Zone states Состояния зон
profile_presence Presence only Только присутствие
invalid_radar Check the radar settings Проверьте настройки радара
not_ready Radar processing is not ready Обработка данных радара не готова
invalid_selection Select an existing room and valid sources Выберите существующую комнату и допустимые источники
conflict Settings changed. Reload before saving. Настройки изменились. Перечитайте их перед сохранением.
rate_limited Too many requests. Try again shortly. Слишком много запросов. Повторите чуть позже.

9. Acceptance criteria and protective witnesses

Every S1-* is required. Proposed test names are not claims of completed tests.

AC Contract Evidence / how it must fail
S1-1 Existing marker entry, explicit enable, global/per-radar switches; no new editor setup smoke, en/ru light/dark golden; auto-enable on discover => no-write assertion fails
S1-2 Exact bindings, deterministic discovery or manual ambiguity, four classes without fabricated capabilities unit/backend profile fixtures; wrong-name auto-binding or inferred bearing => output differs
S1-3 Units, axis/sign/polar conversion are explicit; zero valid, nonfinite rejected shared TS/Python fixtures; replace invalid with0 or inches factor => numeric/rejection tests fail
S1-4 Current independent pairs meet report age/skew; no atomic-frame claim; missing-axis degradation is explained fake-clock backend + setup smoke; last_changed/renew-on-snapshot/stale-axis mutant => hidden-point assertion fails
S1-5 Off/unknown/stale/partial/inconsistent/occupied-without-position distinct; invalid slot clears immediately backend state table and UI golden; unknown=>clear or held invalid slot => state/set assertion fails
S1-6 One coordinator; bounded subscriptions/rate/size; teardown/reconnect never resurrects old data backend multi-client/unload races; remove queue/session/cap guard => count/old-canary assertions fail
S1-7 Physical mount independent of decorative icon; projection uses actual normalized physical scale unit shared fixtures + editor smoke; use wallpaper/viewbox/north or icon position => projected coordinate fails
S1-8 Two reference solve rejects collinear/noisy/ambiguous/multi-target data, no stretch pure solver tests + source replay wizard; remove each guard => invalid-fit acceptance fails
S1-9 Manual/range/zone/presence paths honest; third check independent and optional setup smoke state fixtures; silently alter fit on third check => parameter equality fails
S1-10 Save atomic/revisioned, Cancel/source deletion/revoked permission never persists partial setup two-client smoke/backend writes; skip revision/cancel guard => config/service trace fails
S1-11 Real room polygon clips dots/arcs/zones; missing contour warns, missing room never retargets concave/shared-boundary fixtures + smoke; replace with bbox/defaultroom => excluded-point canary fails
S1-12 8s trail/smoothing are session-local, bounded, gap-safe and reduced-motion aware unit clock + browser resume/rate smoke; bridge gap/persist trail => path/storage assertion fails
S1-13 Live pointer transparency, themes, kiosk/touch, room/device actions and floor projection remain correct ordinary View/golden/gesture smoke; overlay hitbox or unprojected layer => interaction/geometry fail
S1-14 Live source read ACL and setup may_write; no source/raw/history leak in read-only delivery HA user matrix, support/export/storage canary; remove ACL/filter => canary leak fails
S1-15 Optional/future/unknown fields safe; exact refs covered by import/copy/Optimize/removal/scale change backend lifecycle + config roundtrip; retarget/drop sibling/keep invalidcalibration => invariant fails
S1-16 New/old frontend/backend safe; unsupported commands absent; original vacuum/Glow unaffected rolling-matrix smoke, existing vacuum/light unit/golden; API capability bypass => unsupported-call assertion fails
S1-17 Editor lazy, active load bounded and no idle animation/subscriptions when disabled bundle graph + baseline/candidate perf + backend timer count; eager editor or runaway listener => budget fails
S1-18 Text, status announcements and safe desktop/touch exit meet §2/5/8 i18n parity, keyboard/touch smoke, reviewed screenshots; ordinary expected-vs-actual witnesses
S1-19 Main section appears only for recognized radars, an existing saved repair path or explicit session-local manual entry; unavailable/no-target/manual-binding cases retain access, ordinary devices have no automatic section/teaser; global preference remains visible without radars unit eligibility matrix + device-editor smoke/golden for coordinate/range/zone/presence radars, light/switch/temperature/PIR/virtual/unknown devices and saved broken/future config; remove eligibility guard => ordinary-device header assertion fails; gate by on/valid coordinates => offline/range/manual repair assertion fails; hide global switch until first radar => empty-installation settings assertion fails
S1-20 Secondary manual entry supports unknown/custom radars and standalone sources without device_id; only validated atomic Save persists eligibility, Cancel/exit/binding change does not; declaration grants no source or hardware privileges device-editor smoke for unrecognized custom range sensor (no device_id or occupancy), explicit multi-device Cartesian bindings, save/reopen/disable/repair/remove and Cancel/reload; unit/backend ACL, invalid-unit and hardware-adapter negative fixtures. Require auto identity => generic-setup witness fails; persist declaration early => cancel-config comparison fails; bypass ACL or adapter proof => restricted-source/service canary fails

Implementation artifacts: test/radar-geometry.test.mjs, test/radar-model.test.mjs, tests_backend/test_ha_radar.py, shared JSON coordinate/source fixtures, demo/smoke_radar_setup.mjs, demo/smoke_radar_live.mjs, full-scene golden fixtures. Register protective backend/browser mutations per PROCESS. Include axis-aligned motion with one suppressed axis, true stationary presence, delayed HA delivery, unexpected unknown coordinates, concave room, duplicate slots, disconnected client, scale change and private-source canaries — not only moving ideal points. Consented field CSV is supplementary, not a blocking dependency.

Field evidence received on 2026-09-08 was inspected locally and is not stored in the repository. Its 68,367 observations covered all three LD2450 target slots (47,350 / 18,344 / 2,673 rows). It confirms that (x=0,y=0) is the empty-slot sentinel, while a zero on only one axis is a valid target coordinate and signed X distinguishes the two sides of the radar. An approximately 1.4 m apparent zone offset is recorded as Stage-2 calibration evidence; Stage 1 must not apply an undocumented correction for it.

10. Performance, release and rollback

Baseline/candidate workload: 32 configured radars, eight slots each, four output frames/s, three View clients including 360px phone and wall tablet; repeat with all disabled and with only one selected setup stream. No full config/layout rebuild per source frame; listener count depends on unique sources, not clients. Pure normalization/projection callback p95 <=10ms; browser incremental overlay update p95 <=8ms on the project's standard perf runner; no introduced >50ms long task from radar work. Report sample count/hardware/variance with results, not a generic speed claim. Existing suite budgets and initial gzip256000B remain. No continuous RAF when output unchanged/hidden; reduced-motion interpolation off.

Implementation loop typecheck/unit/build; Linux CI full HA harness and required smoke/golden/performance precede beta. Release artifacts follow common §8: both changelogs, guide/setup/data-quality caveat, architecture, compatibility, field registry, reviewed full-scene screenshots light/dark/2D/isometry/mobile, permission and mutation evidence. Current docs-only work runs no implementation gates and claims no shipped changes.

docs/SCOPE.md already records the owner-approved narrow #485 exception as part of this spec revision. Verify the implementation/release against that boundary; do not silently broaden it. The guides and release notes explicitly carry the desktop-first/best-effort-touch calibration limitation from §5.2.

Rollback: hide live to remove visuals; disable radar to detach sources; restore saved config if needed. No Stage1 external write or raw store to reverse. Older frontend editing is discouraged; unsupported fields remain inert. Risks include unknown source conventions, independent coordinate reports, noisy calibration, loss of fresh stationary coordinates and potentially misleading coverage. The explicit degraded states and conservative solver handle uncertainty without pretending the sensor provides data it does not have.

11. Sources and engineering assumptions

Primary constraints: HA 2024.6.0 state model, HA report event, ESPHome LD2450, LD24xx duplicate suppression, ESPHome filter behaviour, LD2410 distance-only data.

Timing/solver tolerances, screen sizes, module names and bounded query/resource limits are explicit engineering choices for independent review, not extra owner questions. Atomic frames are not assumed for separate HA sensors. A backend reporting API is deliberately required for uniform normalization and future browser-independent recording. Exact shared coordinate fixtures, not historical ARCHITECTURE pixel dimensions, are the mathematical acceptance truth. For Q5, session-local declaration state and the collapsed additional-actions group are implementation choices for the accepted secondary-entry behaviour; no extra persisted classification flag is necessary. Generic source ownership means marker/space ownership plus per-entity authorization, not device-registry membership. Hardware adapter identity is a separate verified capability.