From 78afb6896209a806b648969f06a331603f85cdf2 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Tue, 8 Sep 2026 14:04:00 +0300 Subject: [PATCH] docs: specify radar presence stages for #485 Issue: #485 User-Visible: no --- docs/specs/485-radar-presence-stage1.md | 534 ++++++++++++++++ docs/specs/485-radar-presence-stage2.md | 700 +++++++++++++++++++++ docs/specs/485-radar-presence-stage3.md | 799 ++++++++++++++++++++++++ docs/specs/485-radar-presence.md | 244 ++++++++ docs/specs/README.md | 1 + 5 files changed, 2278 insertions(+) create mode 100644 docs/specs/485-radar-presence-stage1.md create mode 100644 docs/specs/485-radar-presence-stage2.md create mode 100644 docs/specs/485-radar-presence-stage3.md create mode 100644 docs/specs/485-radar-presence.md diff --git a/docs/specs/485-radar-presence-stage1.md b/docs/specs/485-radar-presence-stage1.md new file mode 100644 index 00000000..2e007f56 --- /dev/null +++ b/docs/specs/485-radar-presence-stage1.md @@ -0,0 +1,534 @@ +# #485 — Stage 1: sources, calibration and live presence + +Issue: [#485](https://github.com/Matysh/houseplan-card/issues/485). +Read the [common contract](485-radar-presence.md) first. This is a planned +implementation, not code already shipped. The owner requested stopping at S5 +after independent review; this document does not authorize starting S6 now. + +## 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 existing device or placed-entity marker → **Presence +on plan**. The section is collapsed/off until explicitly configured. 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**. Unsupported 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. +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; missing/limited metadata offers manual selection without +claiming deleted or disabled. Existing binding-status authority remains in use. + +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. + +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: the driver also +uses missing values when a slot is unused. 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 + +**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. 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 | Присутствие на плане | +| `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 | + +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. + +## 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. + +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](https://github.com/home-assistant/core/blob/2024.6.0/homeassistant/core.py), +[HA report event](https://developers.home-assistant.io/blog/2024/03/20/state_reported_timestamp/), +[ESPHome LD2450](https://esphome.io/components/sensor/ld2450/), +[LD24xx duplicate suppression](https://github.com/esphome/esphome/blob/2026.8.2/esphome/components/ld24xx/ld24xx.h), +[ESPHome filter behaviour](https://github.com/esphome/esphome/blob/2026.8.2/esphome/components/sensor/filter.cpp), +[LD2410 distance-only data](https://esphome.io/components/sensor/ld2410/). + +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. diff --git a/docs/specs/485-radar-presence-stage2.md b/docs/specs/485-radar-presence-stage2.md new file mode 100644 index 00000000..46178cfa --- /dev/null +++ b/docs/specs/485-radar-presence-stage2.md @@ -0,0 +1,700 @@ +# #485 — Radar presence, stage 2: zones and bounded observations + +Issue: [#485](https://github.com/Matysh/houseplan-card/issues/485). Companion: +[stage 1](485-radar-presence-stage1.md). This is an implementation contract, +not a report that the feature exists. The issue labels remain the sole status. +Owner-approved UX defaults Q1–Q4 apply: configured live display is on, the +allowed area is one selected room, collection is explicit and limited, and +cross-radar merging belongs to stage 3. This document changes no product code. + +## 1. Scenario, persona, before and after + +The home administrator, in the desktop Device editor of the main House Plan +panel or full dashboard card, has calibrated a radar and now asks: “Does the +sofa zone actually cover the detections from my sofa?” Household members and +kiosk users continue seeing the quiet current-presence layer. + +Before: the administrator compares millimetres and a separate spreadsheet. +After: they outline a zone on their plan, collect a limited observation sample, +and see whether observations land inside it before explicitly changing a device. + +This serves J1/J4/J6 in `docs/SCOPE.md`. The approved exception to its generic +history exclusion is a local, bounded spatial-calibration sample; this is not +a history dashboard or a general replacement for HA Recorder. Raw movement +collection and device writes are separately opt-in operations. + +## 2. Scope and non-scope + +Included: local polygon zones; mapping exact existing occupancy/count entities +to those zones (including FP2-style zone-only connections); supported LD2450 +device-zone read/edit/apply; bounded server recording; cloud inspection and +sample fractions; manually drawn reflection filters with preview; permissions, +revision conflicts, interruption, retention and rolling compatibility. + +Excluded: continuous multi-day collection, heat maps, CSV import/export, cloud +upload, support attachment of observations, personal identities, trajectory +fusion, multi-room routing, automatic zone/mirror discovery, HA automation +creation and derived HA occupancy/count entities. Stage 3 must specify these +separately. No new editor tab, always-visible radar console or polling loop +that runs only because a View client is open. No generic arbitrary HA service +proxy and no firmware installation or entity-registry administration. + +Stage 1 is a prerequisite: exact source bindings, normalized live frames, +physical installation, calibration, room filtering and source-health semantics +are consumed, not independently reimplemented. Device-zone support is a +capability of a verified connection, never inferred from a brand name. + +## 3. Existing integration seams and proposed files + +The existing files below were inspected on the issue branch; `radar*` modules +are proposed additions, not existing implementations. + +| Surface | Existing authority | Stage-2 work | +|---|---|---| +| Configuration types | `src/types.ts` (`Marker`, `ServerConfig`) | Add optional zones/reflectors beneath the stage-1 radar object | +| Configuration transactions | `src/config-store.ts`; `custom_components/houseplan/websocket_api.py` (`houseplan/config/set`) | Preserve `expected_rev`, server acceptance and multi-client invalidation | +| Strict/change-aware validation | `custom_components/houseplan/validation.py` | Delegate new/changed radar fields to proposed `radar_validation.py`; retain unknown siblings | +| Authorization | `custom_components/houseplan/auth.py:may_write` | Use the same policy for raw reads, recording and hardware writes | +| Runtime ownership | `custom_components/houseplan/__init__.py`, `store.py:HouseplanData` | Wire setup/unload/reconciliation; separate recorder locks and storage | +| Existing bounded-recorder precedent | `custom_components/houseplan/trails.py:TrailRecorder` | Reuse lifecycle lessons, not vacuum-run semantics or its unrestricted read endpoint | +| Privacy boundary | `custom_components/houseplan/support_package.py`; existing export/import APIs | Exclude operational history and raw telemetry; preserve safe config transfer | +| Presentation | `src/render-device-snapshot.ts`, `houseplan-render-lifecycle.ts`, `src/editors/` | Feed one selected-radar lazy editor; keep View free of history/editor dependencies | + +Proposed additions: `src/radar-zones.ts` (pure geometry/membership), +`src/radar-observations.ts` (typed client), `src/editors/radar-zones-section.ts`, +`src/editors/radar-observations-section.ts`; backend `radar_zones.py`, +`radar_recording.py`, `radar_recording_store.py`, `radar_validation.py` and +`radar_websocket.py`. Stage-1 modules remain the normalized-frame authority. +Names are technical choices, not separate scope. Do not alter `trails.py`'s +vacuum behaviour to fit radar data. + +## 4. UX contract + +### 4.1 One selected source and two kinds of zone + +Entry: Device editor → selected device → Presence on plan → Configure on plan. +The same contextual surface gains **Zones** and **Observation recording**. +Only the selected radar's draft, cloud, sector, filters and short live trail +are visible here. Closing configuration returns to the same device; returning +from another route starts in View. Temporary layers are session-local. + +**House Plan zones**: Add zone → outline a rectangle or polygon → name → +choose its state source → preview → Save. State source is either this radar's +normalized coordinate targets or one exact existing occupancy/count entity. +Zone-only sources show entity selection and polygon drawing, no coordinate +calibration or point recording. FP2 mapping does not write to Aqara and is not +labelled “synchronized”. Deleting the mapping never deletes the HA entity. + +For a coordinate zone, a valid current target inside its polygon means occupied; +zero eligible targets means “no targets detected” only if the source has a +fresh frame with stage-1 `complete:true`. An incomplete frame may establish +positive occupancy from its usable slots, but cannot establish absence from +the remaining slots. Missing/stale coordinates are unknown, never clear. A +mapped binary sensor uses exact `on`/`off`; mapped count uses a finite integer +`>=0`. Unknown/unavailable/invalid values produce unknown. There is no fallback +from a missing selected entity to a similarly named sensor. Overlapping zones +may both be occupied; their counts are never summed as people. + +**Device zones**: a separate section with connection capabilities, one mode +selector above the list, and at most three LD2450 slots. Drawing starts in the +radar's local axes. A sensor-axis rectangle appears rotated on a rotated plan; +no arbitrary polygon is silently converted to a bounding rectangle. Handle +preview, numeric fields, transmitted values and reloaded outline use the same +unit conversion and quantization. An unsupported connection may display known +geometry read-only; the Apply button is absent with an explanation. + +The one LD2450 mode is **Unrestricted / Detect inside / Ignore inside**, +mapping to `Disabled / Detection / Filter`. It applies to the whole set, not +to each zone independently. Unrestricted retains coordinates for later use. +An unused slot is four zeros; this sentinel is not an active zero-area zone. +Every active slot must have `x1`; it is off in View. + +### 4.3 Observation recording and inspection + +No recording starts from opening View, configuring/calibrating a source, +opening this section or enabling live display. The form offers 5 minutes, +15 minutes, 1 hour (default), 24 hours; it explains local storage, browser-close +continuation, automatic stop and rolling 24-hour raw retention before Start. +The server's response, not the click, starts the visible countdown. + +The section shows the shared run state, start/end, retained sample count, source +gaps, recording limits and Stop. Another authorized client sees the same run; +opening it does not create another recorder. Hiding live overlays does not stop +recording. Disabling the radar explicitly stops its run after confirmation; +turning it on again never restarts recording. + +After a run, or during it, choose one retained run/epoch and an interval. The +cloud is a distinct historical layer, without interpolated lines; live targets +stay visually distinct. Switching selected radar disposes the old cloud and +request. Never superimpose history from another source generation by default. +Clear recording confirms permanent deletion of **all retained observations +for this selected radar**, stops its active run, and updates every client. +It leaves calibration, local zones, reflectors and hardware untouched. + +Zone score is “{inside} of {total} recorded points ({percent}%) fall in this +zone”, with interval, excluded-points policy and any sampling/loss disclaimer. +Default denominator includes every valid retained point in the selected epoch, +including points outside the selected room and excluded by filters; the user +may explicitly choose “included points only”. Both numerator and denominator +then use that same predicate. Zero denominator shows “No usable observations”, +not 0%. Overlapping zones score independently. This is neither time occupied, +probability, accuracy nor a pass/fail judgement of zone correctness. + +Desktop is the reference editor. Touch editor: best effort / intentionally +degraded; complex geometry may recommend desktop, but Cancel/exit, permission +guards and destructive confirmation always work safely. View and kiosk retain +full touch support, normal pan/zoom and actions. No point/zone history overlay +intercepts a device tap; kiosk exposes neither recording nor configuration. + +## 5. Persisted models and compatibility + +Stage 1 owns `marker.radar={version:1,enabled,show_live,profile,sources,mount, +room_id,calibration}` and `settings.radar.show_live` (default true). Add optional +`zones` and `reflectors`; absence is equivalent to empty and triggers no write. +No global model/store-version bump and no read migration are required. + +| Path | Contract | +|---|---| +| `radar.zones.local[]` | Maximum 32; `{id,name,poly,state}`; id 1–64 ASCII `[A-Za-z0-9_-]`, unique; name trimmed 1–80 characters | +| `local[].poly` | 3–64 finite `{x,y}` points in the same canonical plan coordinate system as room polygons; simple polygon, no holes, nonzero area; no duplicate adjacent points or repeated closing point | +| `local[].state` | `{kind:'targets'}` or `{kind:'occupancy',entity_id}` or `{kind:'count',entity_id}`; targets requires coordinate capability, others require exact `binary_sensor.*` / `sensor.*` references | +| `radar.zones.hardware` | Optional `{adapter:'esphome_ld2450_numbers_v1',mode_entity,slots[]}`; contains bindings/labels, never an assumed applied configuration | +| `hardware.slots[]` | Exactly three ordered entries `{slot:1\|2\|3,name,x1_entity,y1_entity,x2_entity,y2_entity}`; one complete descriptor is required for writes | +| `hardware.mode_entity` | Exact `select.*`; required options are the adapter's literal three modes | +| `hardware.*_entity` | Exact `number.*`, no templates, wildcard ids, service names or arbitrary attribute paths; globally distinct inside this binding set | +| `radar.reflectors[]` | Maximum 8; `{id,name,a:{x,y},b:{x,y},enabled}`; same plan units, id/name bounds as zones, strict boolean | + +Entity ids use the existing bounded entity-id validation (maximum 255). Physical +conversion uses the stage-1 canonical scale and calibration; neither renderer +nor writer invents a second scale. Geometry is finite, within the space's +existing accepted coordinate envelope, and subject to the total 2 MiB config +limit. New/changed malformed radar records are rejected atomically with +`invalid_radar_zones`; untouched malformed/future records remain lossless and +inert on unrelated writes, as with existing change-aware compatibility fields. +Unknown sibling fields round-trip. Invalid known members never reach rendering +or services. No recursive rounding of unknown future numeric fields. + +Full export/import preserves configuration and exact entity ids; it never +starts a recorder or applies device settings. Space transfer remaps the owner +space/room with the existing reference seam and keeps local geometry with its +owner; HA ids remain literal. If duplicate policy virtualizes a marker, remove +the complete HA-dependent radar block and report one dropped radar binding, +instead of leaving device-write capabilities on a virtual copy. Plan-only +transfer contains no radar marker data. Removing a room preserves the radar +config as needing repair, stops its recording, and does not select another room. + +Runtime snapshots, operation ids, confirmation tokens, applied/draft numbers, +recordings and telemetry do not enter ServerConfig, layout, portable exports, +support packages, localStorage or frontend crash reports. Support projection +may expose capability booleans and bounded counts only, no source ids, names, +coordinates, run times, geometry, raw samples or device command payloads. + +New frontend + old backend: absent `radar_stage2_api:1` hides stage-2 operations +with an update explanation, preserves fields, and makes zero attempted stage-2 +commands. Old frontend + new backend: fields survive unknown-field handling; +ordinary unrelated saves must not strip them. Older clients that reconstruct +markers may erase unknown fields: downgrade is read-only recommended, not +promised lossless editing. Raw storage is independently versioned and ignored +by old backends. Register fields in `scripts/config-field-registry.mjs` and +document this matrix in `docs/CONFIG-COMPATIBILITY.md` during implementation. + +## 6. Device read, draft, conflict and write protocol + +### 6.1 Capability and snapshot + +`config/get` advertises exact `radar_stage2_api:1` after runtime setup. The +server resolves all entity ids from saved config, validates device membership +for the LD2450 adapter (one ESPHome device), current state availability, number +units, min/max/step and the mode options. A manual coordinate profile remains +eligible for local zones/recording but does not gain hardware-write capability. + +LD2450 adapter accepts millimetre number entities and converts local cm ×10; +it rejects missing/unsupported units. Effective numeric bounds are those of the +actual entities, not constants copied from marketing. Documentation and the +ESPHome 2026.8.2 number schema have differed, so numeric fields carry authoritative +limits in the snapshot. Quantize to `min+k*step` nearest, ties away from zero; +the resulting rectangle, including quantization changes, is the confirmed draft. +Out-of-range points are rejected, never silently clamped. + +A snapshot contains all three slots, global mode, exact binding fingerprint, +`config_rev`, `source_generation`, time, `snapshot_hash`, and +`read_proof:'ha_state'|'device_readback'`. HA cached equality and state changes +after `number.set_value` are **not** device-readback proof. The default +`esphome_ld2450_numbers_v1` adapter can offer `ha_state` only; it must advertise +`verified_apply:false` and cannot emit a green device-confirmed result. +This restriction does not stop an explicitly warned, unverified write. + +### 6.2 Draft and explicit confirmation + +Opening reads a baseline; edits stay browser-memory-only. Cancel, leaving the +source, or closing the page asks to discard dirty geometry and sends no command. +Reload loses an unsaved draft; accepted shared polygons are unaffected. If a +configuration update changes this radar's binding/generation, disable Apply, +retain the draft for inspection, and require a fresh baseline. + +Prepare compares the complete current set against the baseline snapshot under +a per-device operation lock. Any observable external change returns conflict +with no write; the UI offers Reload device settings, not silent merge. Changes +made elsewhere but not exposed by HA cannot be detected: the confirmation says +so for `ha_state` proof, and never claims universal conflict detection. + +The server generates a user-bound, single-use confirmation token valid for 60 +seconds, tied to marker, full quantized candidate, baseline hash, config rev, +source generation and exact binding set. Maximum two outstanding tokens per +user and 32 globally; evict oldest unused tokens, not active operations. Apply +accepts this token only, not arbitrary entity ids, services or changed values. + +### 6.3 Apply and outcomes + +Immediately before the first call recheck permission, token, exact ownership, +source generation, config rev and complete available baseline. Changed/unknown +baseline is conflict/unavailable and makes zero calls. Per-device lock prevents +simultaneous writes through two marker bindings to the same hardware. It does +not lock ordinary unrelated config saves for the duration of UART traffic. +Binding/generation mutation during an operation is refused as `radar_busy`; +unrelated config changes do not cancel an already accepted operation. + +Immediately before attempting the first service call, stop any active raw +recording for this physical device as `configuration_changed` and invalidate +its evaluation epoch. When Stage 3 exists, the same signal stops affected +heatmap runs and invalidates fusion/count commissioning. Include this effect +in Prepare/confirmation. Neither partial/unverified completion nor a retry +automatically restarts collection or restores count eligibility; the user +checks the resulting detection and explicitly starts/recommissions. Prepare, +Cancel, or a refusal before an operation is accepted does not stop collection. +This prevents intermediate hardware filters from silently entering an old +observation selection. Independently observed hardware configuration changes +raise the same invalidation; unreported physical changes remain unknowable. + +For the numbers adapter, write all twelve coordinate numbers in slot order +`x1,y1,x2,y2`, then the final mode, sequentially. Send the final mode even when +unchanged so the last command carries the final complete set. Use only +`number.set_value` / `select.select_option`, blocking calls with the requesting +HA user's context and normal HA service authorization; `may_write` is not an +elevation above HA entity control permissions. Timeout is 5 seconds per call, +whole operation 75 seconds. The confirmation warns that separate writes can +expose intermediate detection configurations to existing HA automations; there +is no implicit temporary disable, retry loop or atomicity claim. + +First error, revoked permission or timeout stops remaining calls. Never replay +a timed-out call automatically. Return counts of accepted/failed/not-sent +calls and the affected complete slot/mode set, without asserting which values +the hardware applied. The UI retains the draft and baseline; Retry or Restore +previous values requires a new read/prepare/confirmation. Restore is another +bounded write with the same risks, not atomic rollback. + +Outcomes: `confirmed` only with independent device readback matching the full +candidate; `sent_unverified` when all calls were accepted but proof is absent; +`partial_unverified` after some calls with no proven final state; `failed` +before any accepted call; `partial_confirmed` only when independent complete +readback proves a differing applied set. For the default numbers adapter only +the unverified/failed outcomes are reachable. “Refresh” rereads HA and retains +its proof grade; it cannot upgrade an optimistic snapshot to hardware proof. + +Runtime operation records survive disconnect in memory for 10 minutes (maximum +32 terminal records); repeated Apply using the same token returns the same +operation instead of resending. HA restart loses these records: UI reports +“Result unknown after restart” and requires a fresh read before another write. +There is no startup resend or automatic restoration of hardware settings. + +## 7. Recording model, retention and lifecycle + +### 7.1 Runtime record + +Proposed `RadarRecordingStore` owns separate version-1 HA storage keys beneath +`houseplan.radar_recordings`; only supported storage APIs write them. A small +manifest owns runs and minute-sized sample chunks. Client requests never choose +paths. File I/O and bounded aggregation run off the HA event loop. Raw data is +never placed in the config/layout Store, HA entities or HA Recorder. + +Run header: `{run_id,marker_id,source_generation,started_at,ends_at,stopped_at?, +status,stop_reason?,collection_policy,epochs[],retained_points,lost_samples}`. +The private manifest additionally stores `initiator_user_id` for permission +rechecks; query/status responses do not expose that identifier. +Server UTC timestamps are authority; runtime duration/expiry deadlines use a +monotonic clock so wall-clock corrections do not extend authorized collection. +The source generation and normalized physical centimetre coordinates are taken +from the stage-1 frame before room/reflector filtering. Every accepted sample +records server sequence/time, source `server_session_id`/frame `seq`, slot key, +local x/y, both coordinate-component report times and the inherited +`pair_quality`. Polar samples retain their distance/bearing component report +times after conversion. `bounded_latest` remains bounded HA-report pairing, +never an atomic hardware frame or proof of a new physical measurement. +Slot keys are not identities. No speed/trajectory/person inference is required. + +An observation key is `(source_generation,server_session_id,slot, +component_1_report_time,component_2_report_time)`. A new output `seq`, filter +revision, browser event or elapsed second alone does not make a new observation. +The recorder retains the last accepted key per configured slot and does not +sample it twice, including when a frame is republished for another slot or for +health changes. A verified coherent adapter uses its stage-1 sample key instead; +the recorder cannot infer or upgrade that capability. Newly reported equal +values are allowed only under stage-1 eligibility and remain labelled reports, +not independently verified stationary measurements. + +Epoch header stores `source_generation`, `calibration_revision`, projection +snapshot, room/reflector predicate snapshots and filter/zone revision +fingerprints. Its private storage also holds an immutable `source_entity_ids` +set derived by the backend from the exact bindings contributing to that epoch's +observations and evaluation: coordinate components, gates, availability/count +inputs and any mapped-zone inputs used. This is not reconstructed from the +marker's current bindings after reconfiguration. Exact references are private +authorization metadata, not returned in run/epoch/query/status responses or +portable/support exports, and expire with the epoch under the same raw/metadata +TTL rules; they do not become a permanent source registry. +Historical included-only scoring uses that epoch's predicate; +explicit current recalculation uses the current predicate and labels both +revisions. Zone scores always refer to the currently selected saved zone outline, +whose revision is returned; no score is labelled as an old zone's statistic. +Physical move, source/unit/axis +change, owner-space change, new `installation_id` (including a reinstallation +at the same coordinates) or deleted selected room stops the current run as +`configuration_changed`; restart requires explicit Start. A calibration-only +heading/mirror/reference correction closes the epoch and opens the next within +the same bounded run and source generation, per the common identity contract. +Filter/zone edits likewise delimit evaluation epochs without destroying raw +samples. Historical default view uses its saved epoch projection. Explicit +“Recalculate with current calibration” is permitted only within the same +source generation and explains the changed projection; it never mixes epochs +silently. A physically moved sensor cannot reuse the old generation. + +Collection accepts only stage-1 eligible coordinate observations, checked at +collection time with the same component ages, skew and source-generation rules. +For `bounded_latest`, each component age is at most 3 seconds and report skew +at most 1.5 seconds; storage does not renew this eligibility. Usable slots from +an incomplete frame may be recorded with `complete:false` and explicit missing +slot/gap metadata; missing slots never become zero observations. Coordinate- +unknown, stale, unavailable, source-gap and interrupted intervals produce gaps/ +counters, not fabricated zeros, duplicated observation keys or interpolated +points. A fresh `complete:true` zero-target frame records availability but no +target samples; an empty incomplete frame is a gap, not absence. Occupancy-only, +range-only and zone-only profiles cannot start point recording. + +### 7.2 Hard bounds and durability + +| Bound | Required value/policy | +|---|---| +| Duration | `duration_s ∈ {300,900,3600,86400}`, default 3600; max 86400 per explicit run; no extension/resume endpoint | +| Concurrency | One active run per marker, maximum 8 across the integration; a duplicate Start returns `already_recording`, never extends the deadline | +| Collection rate | At most one eligible frame per server-monotonic second per radar, all usable slots up to the stage-1 slot cap; retain the first frame with new eligible observation keys in each second, skip already accepted keys; no duplicate-counting on output/client events | +| Samples | Maximum 700,000 retained target samples per marker, including all unexpired runs | +| Metadata | Maximum 64 retained runs per marker, 128 epochs per run and 4 MiB manifest data per marker; further Start is refused, or a recording is stopped before opening an excess epoch; ordinary config edits still succeed | +| Storage | Maximum 32 MiB encoded recording data per marker and 128 MiB total, including manifests and pending chunks; stop the affected run as `quota_reached`, never evict another user's unexpired observations | +| Memory | At most one minute of unflushed samples per active run; a global 8 MiB buffer ceiling; overflow stops affected run with `storage_error` | +| Raw TTL | 24 hours from each server sample time, not 24 hours from run completion; exact cutoff enforced on every read/score, plus deletion sweep at startup and at least once per minute | +| Metadata TTL | Empty run headers and operation/sample metadata removed no later than 24 hours after stop; no permanent audit of movement/recording times | +| Browser result | Page size ≤2,000 samples, response ≤512 KiB; opaque cursor ≤256 chars; cloud preview ≤10,000 points by deterministic reservoir selection | +| Requests | Query/score ≤1 in flight per user/marker; start/prepare ≤10 per minute per user; operation payload ≤64 KiB; excess returns `rate_limited`/`too_large` | + +The score uses all retained eligible samples, not the downsampled preview. UI +states capture cadence and “preview sampled” separately; point fraction is not +time fraction. Counts, numerator/denominator and preview derive from one query +selection including the epoch and exclusion policy. + +Persist Start metadata before acknowledging Start. Flush chunks no less often +than every 60 seconds and on Stop/unload; an interrupted write is replaced +atomically by the storage implementation, never appended as an unvalidated +partial record. A crash can lose at most the acknowledged buffer interval, +which is reported as an interruption/loss, not falsely reconstructed. Storage +failure stops recording, preserves prior durable chunks and shows failure. + +Browser close/network loss does not stop a server-authorized run. HA restart, +integration unload/reload, deleted/tombstoned marker, disabled radar or revoked +initiator permission ends collection; on startup retained runs marked recording +become `interrupted`, are swept for TTL, and are never silently resumed. Check +the initiator's existence, current `may_write` and HA read permission for every +exact source entity in the active epoch at least once per minute and before +accepting a new chunk. Revocation stops collection, discards the not-yet-accepted +pending chunk and records `permission_changed`; it does not disclose restricted +data through status. Opening a new epoch performs the same source-set checks +before accepting observations. Config changes reconcile by stable marker id under a +recorder lock; the teardown-closed flag is checked after awaited loads so a +late refresh cannot resubscribe after unload. + +Clear takes the recorder lock, invalidates the active run generation, detaches +collection and cancels pending saves before deleting chunks and metadata. Do +not acknowledge until the deletion is durable; a write failure returns an +error and never says “cleared”. Delayed saves cannot recreate cleared chunks. +Deleting a marker explicitly cascades its recordings (stated in deletion +confirmation); merely hiding/moving its decorative icon does not delete them. + +TTL deletion is the disclosed consent of Start, not orphan-inference file GC. +Expired data remains inaccessible if disk deletion fails; retry and report a +bounded storage incident without logging coordinates. HA backups or copies +made by the administrator are outside the component's deletion guarantee; the +recording warning links this limitation. Downgrade/disabled integration cannot +run the sweep: before downgrade, Stop and Clear with the current version. + +## 8. Commands, errors and authorization + +All commands are authenticated WebSocket commands in proposed +`radar_websocket.py`; no public/signed content URL for raw samples. Every command +below, including status/read/score, requires current `may_write(hass,user)` plus +HA read permission for every exact source entity contributing to its result. +For recording Start and the active run this is the backend-derived epoch source +set. Historical query/score uses the selected epoch's saved source set, not only +the current marker bindings; a current-projection score additionally checks +any current inputs it uses. Status checks the source sets of all retained epochs +whose metadata would be returned. If any required permission is denied or +cannot be resolved, reject the complete response as `source_restricted`; do not +return private run existence, counts, times or a partial cloud. Stop/Clear still +require current `may_write` but may delete/stop without source read access; their +minimal acknowledgement reveals no retained source values or run metadata. +When `admin_only=false`, this intentionally grants the same rights to all +signed-in editors; the settings explanation must say recording visibility +follows this existing policy and the underlying entity read permissions. Do not +substitute a hard-coded `is_admin` check. Device-zone read/prepare/operation +results likewise check the exact hardware binding set; Apply additionally +retains ordinary HA entity control authorization. No global unfiltered source +snapshot is made available merely because `may_write` is true. + +| Command after `houseplan/radar/` | Request | Result | +|---|---|---| +| `zones/read` | `marker_id` | Complete capability/snapshot/proof record from §6 | +| `zones/prepare` | `marker_id,baseline_hash,expected_config_rev,source_generation,candidate` | Quantized full candidate, warning/proof grade, confirmation token/expiry; no service calls | +| `zones/apply` | `token` | `operation_id,status`; progress/result fetched by operation id | +| `zones/operation` | `operation_id` | Bounded outcome and call statuses, without unapproved retargeting | +| `recording/start` | `marker_id,duration_s,expected_config_rev,source_generation` | Persisted run header; exact original end time | +| `recording/status` | `marker_id` | Current state and retained run/epoch headers, no unbounded sample payload | +| `recording/stop` | `marker_id,run_id` | Idempotent stopped header after durable flush; after source-read revocation, minimal stopped acknowledgement without private metadata; does not clear observations | +| `recording/clear` | `marker_id,expected_recording_rev,confirm:true` | Durable deletion result + new recording revision | +| `recording/query` | `marker_id,run_id,epoch_id,from,to,cursor?,limit?` | Bounded samples/preview, effective range, counts, gaps, truncation, recording revision | +| `recording/score` | Same selection + `zone_ids[],included_only,projection:'recorded'\|'current'` | Numerator/denominator/fraction per zone, calibration/filter revision, no new persistent analytics | + +`from/to` are server UTC milliseconds in the retained run, ordered, at most +24 hours apart; maximum 32 requested zone ids. Query cursors bind user, marker, +run, epoch, selection and recording revision. Clear/expiry invalidates them +with `stale_cursor`, not a silently changed page. Query/score reread both +`may_write` and the applicable exact-source permissions before delivery; no +shared unfiltered cross-user cache. Progress subscriptions +are scoped to authorized connections; permission loss closes them and clears +the client buffer. Broad HA bus events contain only invalidation signals, +never sample coordinates, source references or history metadata. + +Stable domain errors: `unauthorized`, `not_ready`, `unsupported_capability`, +`source_unavailable`, `source_restricted`, `permission_changed`, +`invalid_radar_zones`, `invalid_selection`, `conflict`, +`radar_busy`, `invalid_token`, `token_expired`, `already_recording`, +`quota_reached`, `rate_limited`, `too_large`, `stale_cursor`, `storage_error`, +`operation_unknown`. Error text is localized from codes; backend exception +strings and arbitrary entity attribute contents are never displayed or logged +as raw data. Rejected commands leave config, recording deadline and hardware +unchanged unless the returned outcome explicitly records an already-started +partial hardware operation. + +## 9. i18n contract (English + Russian) + +Use the existing i18n registry, lazy editor graph and HA duration/number +formatters. Keys below live under `radar.` in en/ru; other languages retain the +existing fallback policy. Dynamic names are text, never HTML. Existing shared +Save/Cancel/Retry/Close and common error keys are reused, not duplicated. + +| Key | English | Русский | +|---|---|---| +| `zones.title` | Zones | Зоны | +| `zones.local` | House Plan zones | Зоны в House Plan | +| `zones.device` | Device zones | Зоны датчика | +| `zones.add` | Add zone | Добавить зону | +| `zones.state_source` | Zone state source | Источник состояния зоны | +| `zones.targets` | This radar's targets | Цели этого радара | +| `zones.occupancy` | Presence sensor | Датчик присутствия | +| `zones.count` | Target count sensor | Счётчик целей | +| `zones.map_only` | This outline is stored only in House Plan | Этот контур хранится только в House Plan | +| `zones.unrestricted` | Unrestricted | Не ограничивать | +| `zones.detect_inside` | Detect inside | Обнаруживать внутри | +| `zones.ignore_inside` | Ignore inside | Игнорировать внутри | +| `zones.apply` | Apply to device | Применить к датчику | +| `zones.apply_warning` | This changes device detection and may affect existing automations. Separate commands can temporarily apply intermediate values. | Изменится обнаружение датчика; это может повлиять на автоматизации. Отдельные команды могут временно применить промежуточные значения. | +| `zones.collection_stop` | Applying stops active observations/heatmap collection for this device and requires checking combined counts again | Применение остановит активный сбор наблюдений/тепловой карты этого датчика; объединённый подсчёт потребуется проверить заново | +| `zones.external_change` | Device settings changed. Reload before applying. | Настройки датчика изменились. Перечитайте их перед применением. | +| `zones.cached_warning` | Only Home Assistant values are available. Device application and unreported external changes cannot be verified. | Доступны только значения Home Assistant. Нельзя проверить применение датчиком и неотражённые внешние изменения. | +| `zones.confirmed` | Application confirmed by device | Применение подтверждено устройством | +| `zones.unverified` | Commands sent; device application is unconfirmed | Команды отправлены; применение устройством не подтверждено | +| `zones.partial_unverified` | Some commands were sent; the device result is unknown | Отправлена часть команд; результат на устройстве неизвестен | +| `zones.partial_confirmed` | Device readback confirms a different applied set | Датчик подтвердил применение отличающегося набора | +| `zones.restore` | Restore previous values | Восстановить предыдущие значения | +| `zones.unknown_after_restart` | Result unknown after restart. Read settings again. | После перезапуска результат неизвестен. Перечитайте настройки. | +| `recording.title` | Observation recording | Запись наблюдений | +| `recording.duration` | Collect for | Собирать точки | +| `recording.start` | Start recording | Начать запись | +| `recording.stop` | Stop | Остановить | +| `recording.clear` | Clear recording | Очистить запись | +| `recording.clear_confirm` | Stop recording and permanently delete all retained observations for this radar on every client? Zones and calibration stay unchanged. | Остановить запись и навсегда удалить все сохранённые наблюдения этого радара у всех клиентов? Зоны и калибровка останутся. | +| `recording.privacy` | Stored locally. Continues after closing this page. Each point expires after 24 hours; administrator backups are outside this deletion guarantee. | Хранится локально. Запись продолжится после закрытия страницы. Каждая точка удаляется через 24 часа; гарантия удаления не относится к резервным копиям администратора. | +| `recording.editors_only` | Recording requires House Plan editing and source read permissions | Для доступа к записи нужны права редактирования House Plan и чтения источников | +| `recording.ends_at` | Recording ends: {time} | Запись закончится: {time} | +| `recording.interrupted` | Recording interrupted; start a new run to continue | Запись прервана; для продолжения запустите новую | +| `recording.gap` | No valid coordinate data during this interval | В этом интервале нет достоверных координат | +| `recording.no_points` | No usable observations | Нет пригодных наблюдений | +| `recording.score` | {inside} of {total} recorded points ({percent}%) fall in this zone | {inside} из {total} записанных точек ({percent}%) попали в эту зону | +| `recording.not_time` | This is a fraction of samples, not occupied time | Это доля отсчётов, а не время занятости | +| `recording.included_only` | Included points only | Только учитываемые точки | +| `recording.sampled` | Preview sampled; score uses the full retained selection | Превью прорежено; оценка использует всю сохранённую выборку | +| `recording.cadence` | Up to one eligible reported frame per second | Не более одного пригодного кадра по сообщениям источника в секунду | +| `recording.quota` | Recording stopped at the storage limit | Запись остановлена по лимиту хранения | +| `recording.storage_error` | Recording stopped because storage failed | Запись остановлена из-за ошибки хранения | +| `recording.epoch` | Installation/calibration period | Период установки/калибровки | +| `recording.recalculate` | Recalculate with current calibration | Пересчитать с текущей калибровкой | +| `recording.configuration_changed` | Configuration changed; start a new recording | Настройка изменилась; начните новую запись | +| `reflectors.title` | False reflections | Ложные отражения | +| `reflectors.add` | Add reflecting surface | Указать отражающую поверхность | +| `reflectors.warning` | Hides the opposite side of the entire line, possibly including real targets. Does not change the original Home Assistant sensor. | Скрывает противоположную сторону всей линии, включая возможные настоящие цели. Исходный датчик Home Assistant не изменяется. | +| `reflectors.excluded` | Show excluded points | Показать исключённые точки | +| `reflectors.invalid` | Move the line away from the sensor and give it two distinct endpoints | Отодвиньте линию от датчика и задайте разные конечные точки | +| `errors.unsupported_capability` | This connection does not support this operation | Это подключение не поддерживает операцию | +| `errors.update_backend` | Update the House Plan integration to use zones and recordings | Обновите интеграцию House Plan для зон и записи | +| `errors.permission_changed` | Permissions changed; recording access is closed | Права доступа изменились; доступ к записи закрыт | +| `errors.source_restricted` | No access to all sources used by this observation period | Нет доступа ко всем источникам этого периода наблюдений | +| `errors.invalid_geometry` | Check the zone outline and device limits | Проверьте контур зоны и пределы датчика | +| `errors.busy` | This radar is already being updated | Этот радар уже обновляется | + +## 10. Acceptance criteria and negative witnesses + +Each `S2-*` criterion is mandatory. Test names/files below are planned artifacts, +not claims that tests already ran. A protective criterion needs an explicit +negative probe/mutation and its failing output in code review; expensive +backend/browser guards receive a registered mutation-gate witness. + +| AC | Observable contract | Proof | What makes the protection fail visibly | +|---|---|---|---| +| S2-1 | Only selected-radar configuration exposes zones/cloud; View/kiosk remain quiet and pointer-transparent | `smoke_radar_zones.mjs`; golden en/ru light/dark; existing kiosk smoke | Render history or hit areas in View: tap/pan assertions fail | +| S2-2 | Polygon CRUD is revisioned, cancellation has no effect, unknown siblings survive reload/import | unit `radar-zones`; backend config roundtrip; smoke two clients | Remove expected revision check or reconstruct known keys only: stale writer/unknown sentinel tests fail | +| S2-3 | FP2-style occupancy/count mapping uses exact entity; coordinate-zone clear requires complete:true; unknown never means clear, no fabricated coordinates | unit state table including one usable outside-zone slot plus one unknown slot; backend exact-ref fixture; smoke missing selected entity | Enable name fallback, cast unknown to zero or treat an incomplete frame as clear: wrong-state and zero-service-call assertions fail | +| S2-4 | LD2450 uses max three sensor-axis rectangles and one mode; preview equals transmitted quantized values | shared TS/Python geometry fixtures; device-write harness | Add fourth slot, mixed per-zone modes or plan-axis bounding box: strict rejection/projection assertions fail | +| S2-5 | Invalid domains, ownership, units, limits, degenerate geometry and empty Detection sets cause zero service calls | `test_ha_radar_zones.py` table | Remove each guard; invalid candidate reaches captured HA service spy | +| S2-6 | Draft preparation is non-mutating; stale baseline/config/token refuses before writing | backend concurrency/token tests; two-client smoke | Accept consumed/other-user/expired token or changed binding: forbidden service-count assertion fails | +| S2-7 | Default numbers adapter never calls optimistic equality device confirmation | backend optimistic-state fake; smoke unverified label | Treat HA state echo as device proof: expected amber/unverified outcome fails | +| S2-8 | Partial send/timeout stops further calls; retry/restore requires new confirmation; no automatic restart replay | injected nth-call failures and restart; smoke recovery | Retry timed-out call or continue after failed call: service trace differs; false green label fails | +| S2-9 | Recording starts explicitly, duration options/default/caps exact; duplicate start preserves deadline | backend fake-clock tests; smoke Start/Stop | Auto-start on subscription, accept 86401 s or extend duplicate deadline: start-count/deadline assertions fail | +| S2-10 | Browser close leaves run collecting; HA restart interrupts without resume; Stop durably flushes | `test_ha_radar_recording.py`; disconnect/restart harness | Tie recording to WS lifetime or resume at startup: expected sample count/state fails | +| S2-11 | Stage-1 pair quality/ages and unique observation keys survive recording; incomplete slots create explicit gaps, legitimate axis zero survives, no interpolation | shared normalized-frame fixture including bounded_latest, reused output seq and partial slots; backend fake clock | Upgrade quality, renew report age, count a repeated pair under a new seq, fill missing slots or drop `x=0,y>0`: exact samples/quality/gaps differ | +| S2-12 | Raw TTL is per sample; expired samples inaccessible after clock jump/startup, disk failure does not expose them | backend fake clock + storage failure | Remove read-time cutoff while sweep fails: expired canary returns and test fails | +| S2-13 | Concurrent runs/bytes/samples/buffers/query sizes are bounded without fresh-data eviction | backend quota/concurrency tables | Remove each limit or count only flushed bytes: 9th recorder/oversize request is accepted and test fails | +| S2-14 | Raw/status/score require may_write plus exact historical epoch-source ACL; collection rechecks initiator write/read rights; HA writes retain control authorization; Stop/Clear can safely remove data after source-read revocation | HA users with admin_only true/false, rebound marker with restricted old source, revoked source/read/write access before chunk, minimal Stop/Clear response | Check only current bindings, omit metadata/source/chunk ACL, expose private source ids or bypass service authorization: historical canary/metadata/service spy appears | +| S2-15 | Clear stops and durably erases selected radar across clients; delayed callbacks cannot resurrect data | backend racing clear/save/reload; two-client smoke | Permit late flush after generation invalidation: cleared canary reappears | +| S2-16 | Different source generations never mix; changed calibration is explicit epoch/reprojection | backend epoch fixtures; cloud smoke | Join epochs or apply new mount to old generation: known geometry/selection assertion fails | +| S2-17 | Score uses full retained selection with matching numerator/denominator, not preview or elapsed time | unit/backend 100-point fixture: 31 inside; empty, overlaps, exclusions | Use preview size/time weights or filter one side only: exact 31/100 and empty-state assertions fail | +| S2-18 | Reflection/room preview and saved filter agree; no upstream sensor write or history deletion | shared geometry fixture; smoke Apply/Cancel; service spy | Flip side, treat segment as finite without label, or call service: point set/zero-call assertions fail | +| S2-19 | Exports/support/logs/cache carry no recording canary; imports never start/write hardware | backend export/support test; browser storage/network inspection | Add raw data to config/event/support/localStorage: canary scanner fails | +| S2-20 | Missing stage-2 capability, malformed/future fields and downgrade remain inert without data erasure | rolling client/backend matrix; unit preservation tests | Call unsupported command or drop future siblings on unrelated save: trace/roundtrip differs | +| S2-21 | Desktop polygon/line gestures cancel safely; touch pinch/cancel sends no geometry or device writes; View still works | targeted desktop and touch safety smoke | Turn cancelled pointer into commit: config rev/service trace unexpectedly changes | +| S2-22 | Collection/preview stay within §7 budgets and do not load the editor graph in View | backend load test, selected-radar browser perf, bundle budget | Unbounded response or editor import in initial graph exceeds cap; baseline/candidate artifact exposes regression | + +## 11. Test execution and performance/security evidence + +Implement shared fixtures for local centimetres → calibrated plan coordinates, +rectangle quantization, polygon edges, exclusion line side and generations. +Include real-data-shaped ghosts, off-room valid targets, count-only zones and +deliberately malformed snapshots. A contributed CSV may become a test fixture +only after contributor consent and removal of identifying metadata; synthetic +fixtures are sufficient for mandatory automated contracts. + +Planned commands: `npm run typecheck`, `npm test`, `npm run build`, +`npm run bundle:sync`, `npm run bundle:budget`, +`node demo/smoke_radar_zones.mjs`, `node demo/smoke_radar_recording.mjs`, +`node scripts/check-docs.mjs`, and `python -m pytest tests_backend -q` in the +supported Linux/WSL HA harness. Native Windows pure tests do not prove the HA +authorization/storage harness. Register the new protective backend/smoke +witnesses in `scripts/mutation-gate.mjs` during implementation. + +Capture a baseline/candidate artifact using eight synthetic active recorders, +one 10,000-point selected cloud, and a normal large-house View client. UI +updates cloud at most once per second; no full config rebuild per sample, no +per-point SVG nodes and no continuous polling in View. Storage/score work must +yield or run in an executor; HA event-loop callback p95 budget is 10 ms for +accepted frames. Query/score cancels stale client work; at most one request +per selected source. Report actual elapsed times, encoded bytes, buffer peaks, +and frame/callback counts, not a generic “fast”. Initial View dependency graph +must remain within the existing 256,000-byte gzip budget. Existing selected +render/interaction/performance gates still apply to touched modules. + +Security artifact: table of authenticated editor/read-only/disabled-integration +and revoked users, each command exercised, raw canary leakage checks, service +authorization outcomes, quotas and negative witnesses. No public screenshot +or release artifact contains a real home's raw cloud without separate consent. + +## 12. Risks, rollback and release artifacts + +Risks: optimistic HA numbers cannot confirm UART persistence; intermediate +device states can affect automation; room/reflection filters can hide real +targets; biased/throttled sampling cannot measure occupied time; source +reconfiguration can invalidate old geometry; rights changes and late callbacks +can leak/revive raw data; big recordings can block I/O or exhaust storage. +The explicit proof grades, epoch boundaries, server guards and caps above are +required mitigations, not optional improvements. + +Operational rollback: Stop recording, Clear observations with the current +version, disable local reflectors/zone display or set `radar.enabled:false`. +This does not revert device settings: use explicit Restore previous values +with fresh confirmation, or the vendor's own editor. A code rollback preserves +optional config fields and makes unsupported operations inert; recommend no +marker editing with older frontends. There is no migration that silently +deletes user configuration and no automatic hardware rollback. Merely disabling +`settings.radar.show_live` hides output but is not a privacy Stop control. + +Implementation release must include both `docs/CHANGELOG.md` and +`docs/CHANGELOG.ru.md`, issue links, updated `docs/USER-GUIDE.ru.md` (desktop +recommendation, permissions, hardware warnings, retention/backups), English +user-facing documentation, `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, +field registry and `docs/DEVELOPMENT.md` for HA harness/device proof caveats. +Golden/screenshots: selected local/device zones, rotated LD2450 rectangle, +FP2 mapping, cloud/score, partial/unverified outcome, ordinary unchanged +View/kiosk in light/dark en/ru. Capture/accept only through the repository's +Linux CI/WSL policy and reviewed complete artifacts, never by accepting a +partial baseline merely to make a gate green. Include the performance/security +artifacts from §11. Update status/version/release notes only for the actually +delivered stage, never claim heat maps/fusion shipped with this stage. + +Exact-SHA Validate and required heavy gates precede a beta/RC; stable is later +promotion-only. This specification commit needs no product build, new tests, +screenshots, changelog claiming shipped behaviour, or product version bump. +Reaching S5 authorizes future development through the process; it does not +start implementation under the current user's documentation-only request. + +## 13. Technical assumptions — accepted provisionally, change freely in review + +The owner has settled visible defaults; no unanswered product choice remains +here. Module names, WS suffixes, HA Store shard format, resource caps, token +lifetime, quantization tie rule and test filenames are concrete engineering +choices for review, not owner questions. They may change with equivalent +boundedness, privacy, UX and acceptance witnesses. Runtime storage must remain +separate from portable configuration regardless of implementation format. + +Primary constraints checked for this task: [ESPHome LD2450](https://esphome.io/components/sensor/ld2450/), +[number bounds in ESPHome 2026.8.2](https://github.com/esphome/esphome/blob/2026.8.2/esphome/components/ld2450/number/__init__.py), +[optimistic number publication](https://github.com/esphome/esphome/blob/2026.8.2/esphome/components/ld2450/number/zone_coordinate_number.cpp), +[Aqara FP2 connection capabilities](https://www.aqara.com/en/product/presence-sensor-fp2/). +The skill Home Assistant Best Practices influenced the use of exact entity +references, native authorization/storage boundaries and zero implicit HA +automation/entity administration; it does not add a separate product dependency. diff --git a/docs/specs/485-radar-presence-stage3.md b/docs/specs/485-radar-presence-stage3.md new file mode 100644 index 00000000..bc4e8cd7 --- /dev/null +++ b/docs/specs/485-radar-presence-stage3.md @@ -0,0 +1,799 @@ +# #485 — Radar presence, stage 3: coverage and use of room results + +Issue: [#485](https://github.com/Matysh/houseplan-card/issues/485). +Prerequisites: [stage 1](485-radar-presence-stage1.md) and +[stage 2](485-radar-presence-stage2.md). This is a proposed implementation +contract, not a claim of implementation or an independent review. Issue labels +remain authoritative. Read the [common contract](485-radar-presence.md) first. +Owner defaults Q1–Q4 are accepted; §8 specifies the bounded engineering +interpretation of the separately requested weekly heatmap, not permanent +collection permission attributed to Q3. Current instruction stops at S5-ready. + +## 1. Scenario, persona, before and after + +The home administrator opens the existing Device editor on a desktop after +calibrating individual radars. They want to find gaps between rooms, remove +duplicate observations in overlaps and deliberately expose a useful room result +to HA. Household members and guests continue using the quiet touch-first View. + +Before: two radar marks can be mistaken for two people, and each integration's +numbers must be interpreted separately. After: the administrator checks the +selected coverage and sources on the plan; household members see a conservative +current result, with uncertainty shown instead of an invented count. + +J1/J4/J6 in `docs/SCOPE.md` are the relevant jobs. This is the issue-specific +spatial-presence exception, not a general analytics dashboard, device manager +or automation builder. The full track is required: new UX and public WS/native +HA contracts, persisted optional fields, several surfaces and performance and +privacy effects all fail the `small` criteria. + +## 2. Scope, non-scope and delivery boundaries + +Included: + +- analysis of selected calibrated radars and explicitly hypothetical sectors; +- an explicit per-radar allowed-room list, extending the single-room default; +- conservative association of overlapping Cartesian observations and short + room handoff; no personal identity or persistent tracking; +- explicitly created integration-owned room `binary_sensor` and optional + estimated-count `sensor` entities, independent of browser lifetime; +- optional day/week spatial aggregate heatmap with §8's separate opt-in; +- assisted reflection-line proposal from explicit correspondences, with manual + acceptance through stage 2's existing filter preview/save contract. + +Excluded: RF propagation or purchase recommendations, walls/material attenuation +simulation, floor/3D tracking, general statistics, automatic hardware changes, +HA automation/scene/script creation, management of source entities, person IDs, +cross-day trajectories, raw retention longer than 24 hours, automatic continuous +multi-day recording, CSV transfer, cloud upload, history in default View and +silently inferred room/zone/reflector geometry. + +Stage 3 does not weaken stage 1/2 source freshness, exclusions, raw permissions, +single-source diagnostics or device-command confirmation. An unavailable +stage-3 capability leaves stage 1/2 working. Coverage, fusion and derived +entities do not require heatmap collection or access to saved observations. + +## 3. Inspected seams and proposed implementation modules + +These existing authorities were read on `issue/485-radar-presence`; names in +the proposed column are additions, not assertions that implementations exist. + +| Existing seam | Stage-3 responsibility / proposed additions | +|---|---| +| `src/types.ts`, `src/config-store.ts` | Optional radar room-list/settings types and lossless revisioned writes | +| `src/houseplan-editor-runtime.ts`, `src/hp-dialog.ts`, `src/hp-confirm.ts` | Existing editor/dialog ownership; lazy `src/radar-analysis.ts` and `src/radar-analysis-view.ts` | +| `src/houseplan-card.ts`, `src/render-device-snapshot.ts` | Consume backend result without association calculations in the render loop | +| `custom_components/houseplan/websocket_api.py` (`async_register`, `_check_write`, `_runtime`) | Register bounded authenticated analysis/lifecycle commands | +| `custom_components/houseplan/auth.py` (`may_write`) | Existing write policy; no separate radar ACL | +| `custom_components/houseplan/store.py` (`HouseplanData`, `create_data`) | Separate versioned operational/aggregate stores and conflict protection | +| `custom_components/houseplan/__init__.py` (`async_setup_entry`) | Set up/unload authoritative coordinator and new native entity platforms | +| `custom_components/houseplan/validation.py`, `import_export.py`, `projection.py`, `support_package.py`, `diagnostics.py` | Delta validation, transfer policy, privacy projections | +| `custom_components/houseplan/virtual_lights.py`, `trails.py` | Existing operational-lifecycle precedents only; do not reuse vacuum records for people | +| New backend modules | `radar_fusion.py`, `radar_analysis.py`, `radar_heatmap.py`, `radar_room_outputs.py`, `binary_sensor.py`, `sensor.py` | +| `src/i18n/en.json`, `src/i18n/ru.json`; backend `strings.json`, `translations/en.json`, `translations/ru.json` | Keys listed in §12, translated native names/errors | + +Currently this integration initializes storage and a trail recorder; it does +not already expose the proposed room entity platforms. Their config-entry +platform lifecycle is new work. Do not implement derived entities by writing +arbitrary state through REST, editing `.storage` directly, generating templates +or creating YAML automations. Native entity values are memory-backed and +subscription-driven; stable unique IDs register them normally. See +[HA entity contract](https://developers.home-assistant.io/docs/core/entity/) and +[HA entity registry](https://developers.home-assistant.io/docs/entity_registry_index/). + +## 4. UX and permissions + +Entry: **Device editor → Presence on plan → Additional tools → Coverage and +room results**. The primary House Plan panel and full dashboard card share it. +No fourth editor, permanent console, automatic dialog on upgrade or extra View +toolbar. The compact Room View renderer is not extended by this stage. The +selected workspace replaces the device properties with a large +plan and one compact contextual panel; Back returns to the same device. + +1. **Coverage:** select actual sources; only those sources' sectors and their + physical installation anchors appear. **Add trial sector** creates a dashed, + explicitly hypothetical in-session shape, not a Marker, HA entity or device. +2. **Allowed rooms:** a named multiselect, initially the stage-1 room. Save + previews newly included/excluded observed points and affected room outputs. +3. **Combine observations:** select a group, inspect separate/associated marks, + run the short commissioning check and explicitly enable its result. Details + explain ambiguity without exposing tuning coefficients to the ordinary user. +4. **Create room sensors:** choose rooms and exact sources, preview names, + signal types, uncertainty and external retention warning, then confirm. +5. **Heatmap:** a separate opt-in collection action and day/week selector; never + implied by opening analysis, viewing coverage or starting a stage-2 raw run. +6. **Reflection suggestion:** explicitly choose pairs, inspect a proposed line, + then use the existing stage-2 excluded-side preview and Save. Nothing applies + while merely selecting pairs or asking for a suggestion. + +View remains current-only. Association changes marks, not source-marker tap +actions. The full card/panel's room information may show **Presence detected**, **No presence detected**, +**Estimated people: 2**, or **Estimate unavailable**; it never says the home is +definitively empty. No global house count is introduced. When association is +ambiguous, keep independent source marks and say **Observations cannot be +combined reliably** in details, not a confident sum. Optional counts always +retain the word **Estimated**; source slots are not named people. + +All analysis, raw witnesses, aggregate history, settings and start/stop/clear +commands require `may_write` on the server. Ordinary authenticated users retain +only the stage-1 safe live projection and current room summaries. Existing HA +permission policy controls access to explicitly created native entities; House +Plan does not promise its private-history ACL can hide HA entity state. +Source reads and preview/create validation also enforce the calling user's HA +entity-read permissions; `may_write` alone cannot authorize another entity. +Persisted derived-output authorization does not relax source access controls. + +Native entity creation/removal additionally requires HA admin permission because +it changes integration-owned registry lifecycle. A House Plan editor who is not +an HA admin may prepare the plan result but sees a clear **Ask an HA +administrator to create entities** message. No inferred grant from client UI. + +Touch editor: **best effort / intentionally degraded** for precise geometry and +pair selection, with desktop recommendation. View/kiosk remain fully supported: +44 px effective actions, no hover dependency, no hit interception by marks, +room/device actions and pan/pinch preserved. At 360 px the analysis controls and +room information stack vertically. Escape/Back and canceled/multi-touch +gestures cannot accept settings, create entities or start collection. + +## 5. Coverage and allowed-room contract + +Geometry uses the existing canonical plan/centimetre conversion and physical +`radar.mount`, never the decorative marker layout. Coverage is the union of +selected geometric sectors clipped to the union of explicitly allowed room +polygons. The label is always **Indicative geometric coverage — not a guarantee +of detection**. Wall/furniture geometry is context only: no ray-through-wall +claim, RF shadow, attenuation or confidence score. + +- Use verified angle/range only when actually provided; otherwise require an + explicit administrator estimate labeled **Manually estimated**. Missing + parameters mean **Coverage unknown**, not a filled default sector. +- Trial sectors have an explicit manual origin/direction/range/angle. At most + 8 per analysis session, discarded on exit/reload; no purchase or connection + implication. Actual and trial coverage totals are separate and never combined + under the word **Installed**. +- Optional uncovered area is the geometric difference inside the selected + valid polygons, in the same measured units as the plan. It says nothing about + detection probability. Exclude rooms without valid contours from the numeric + denominator and list them as unknown; never invent a raster-room boundary. +- `marker.radar.allowed_room_ids` absent means the stage-1 `room_id` singleton; + an explicit empty array means no rooms, not inheritance. Maximum 32 unique + existing room IDs, all in the radar's space. Known polygon boundaries are + authoritative. Unknown/missing room IDs survive unrelated writes but are + inert and visibly require repair; they are not retargeted by name or HA Area. +- With the list absent, preserve the complete stage-1 single-room behaviour: + an existing room without a contour still allows calibrated unclipped live + observations with its warning; a missing room reference suspends output. + Stage 3 does not turn the former into the latter. For an explicit multi-room + list, a contourless room contributes no invented boundary or assignment: + coverage and point-derived room attribution/count remain unknown where the + required real polygons are absent. Exact room-mapped binary evidence remains + usable under its own contract; it does not invent coordinate membership. +- At a point within 10 cm of more than one allowed room boundary, retain its + visible observation but mark room assignment uncertain. Do not count it in + two rooms. This boundary band is measured in centimetres, not screen pixels. +- Room-only/zone-only presence is attributed only to an explicit exact room + mapping. A single broad occupancy signal allowed across several rooms does + not turn every room on; room attribution is unknown. Range-only data likewise + cannot become Cartesian points or room counts. + +Editing allowed rooms invalidates the affected fusion/output revision and any +active analysis preview. It does not change source HA states or hardware zones. +Any newly allowed room requires explicit Save; room split/merge, Area changes, +imports or discovery never expand the list silently. + +## 6. Conservative association, count and room handoff + +### 6.1 Input and bounds + +One backend coordinator consumes the normalized stage-1 frame: source identity, +`source_generation`, `calibration_revision`, `server_session_id`, `seq`, report +times, `pair_quality`, availability and raw/projected centimetre targets. Apply stage-2 room/reflector exclusions +through the same authoritative projection before fusion. A frontend must not +merge another time, retain stale coordinates or supply its own accepted frame. + +At most 4 enabled groups per installation, 2–8 radars per group and at most 8 +slots per radar under the common 32-radar ceiling. A radar belongs to one enabled +group only. Group sources must share one space and calibrated metric frame; +cross-floor groups are invalid. Only valid Cartesian observations participate; +range, raw count, occupancy and zone-only sources are not point substitutes. + +Evaluate at most 4 Hz. Preserve the source's `pair_quality`; geometric agreement +does not upgrade `bounded_latest` to `coherent`. There are two explicit branches: + +- **Coherent:** only a verified adapter-provided sample sequence/time establishes + atomic pairs. Use shared-time normalized sample skew ≤250 ms when the adapter + can prove comparable clocks, candidate distance ≤60 cm and at least 3 distinct + accepted sample witnesses from each participating radar spanning ≥500 ms. + Coherent local pairs without comparable cross-source clocks use the bounded + estimate branch below; do not invent clock synchronization. +- **Bounded-latest estimate:** the normal independent-HA-entity path, including + `esphome_ld2450_v1`, is eligible for conservative estimated association after + commissioning. Every axis retains Stage 1's ≤3 s report age and ≤1.5 s + within-pair report skew. Compare the report interval from oldest to newest + axis; intervals must overlap or be separated by ≤250 ms, and their latest + report times must differ by ≤1.5 s. Candidate distance is ≤40 cm, with at + least 3 distinct accepted pair witnesses from each participating radar + spanning ≥2 s and mutually unique candidates throughout. These are report- + time estimates, not atomic measurements or a physical-freshness claim. + +A distinct witness requires a new source report/sample tuple and frame sequence +in the same server session/epoch; reevaluating one cached frame three times is +one witness. For independent X/Y the tuple includes both axis report times. +Health-only frames and reordered/duplicate delivery cannot add witnesses. No +branch extends Stage 1's leases or synthesizes an unchanged axis: stationary +suppressed reports, expired axes, inadequate new samples or temporal ambiguity +retain separate valid observations and an unknown affected count. Mixed-quality +groups use the bounded estimate branch and expose that quality in details. + +Build a 60 cm spatial grid per selected group and examine local neighbours. +At most 8 candidate neighbours per observation. Exceeding that cap marks the +local component ambiguous; do not truncate candidates and choose whichever +arrived first. No all-installation Cartesian pair matrix or unbounded assignment +search. Compute at most 64 active observations/group, at most 512 directed +candidate edges/group/evaluation. + +### 6.2 Association rule + +Merge only mutually unique candidates from different radars after the temporal +witness above. Each resulting component contains at most one observation per +source, and every pair of its members must satisfy the candidate rule: A–B and +B–C never imply A–C without evidence. Use the deterministic median projected +position, keeping provenance internally; neither nearest-source tie-breaking +nor a slot number is evidence of identity. + +Near-equal alternatives, two close real people, crossing paths, duplicated slots +from one radar, excessive report/sample skew, missing required report metadata, saturation, stale frame +or source/config epoch change immediately invalidate the affected association. +Fallback is separate valid observations and unknown affected count. Never +silently choose the lower count, the higher count or a fractional person. + +Commissioning is explicit per group: one-person traversal of each intended +overlap followed by two simultaneously detected separated targets. It combines +an explicit user's attestation about this physical exercise with backend proof +that the required distinct frames, source epochs, pair quality, uniqueness and +separated-target observations actually occurred. A client boolean alone cannot +commission a group, and backend samples cannot prove the user's physical claim. + +The commissioning inspect API in §11 starts a user-requested ephemeral capture +for one selected saved group, at most 8 radars and 30 seconds per single-target +or separated-target phase. At most one capture/user and four globally. Each +phase requires the branch-specific distinct-frame proof for every participant; +the separated phase must show two simultaneous targets at least 120 cm apart, +without merging them. Every intended overlap must have a witnessed comparison; +otherwise return an incomplete proof and keep count ineligible. Allow explicit +repeat of the missing phase; never silently continue a capture beyond 30 s. +Keep only bounded proof summaries after a phase, not a retained path; summaries +expire after 10 minutes, at most two/group and four groups/user. Detailed +in-memory capture is discarded on completion/cancel, disconnect or epoch change. +It starts neither a stage-2 raw run nor heatmap collection. + +Accept takes the user-bound proof token and explicit attestation, rechecks +source read/write permissions and config revision, and computes the accepted +fingerprint from server-owned group/source/calibration/filter/room epochs and +algorithm version. Save a server-issued receipt and summary in config through +the existing revisioned config transaction, not coordinates or the person's +path. Generic config/set cannot mint/alter that receipt; unchanged server-issued +metadata round-trips, and changed/imported group metadata loses eligibility. +Any changed fingerprint input suspends count until a repeated check. Passing +commissioning permits only the corresponding coherent/bounded estimate branch, +never promotes source quality or certifies future identity; live guards remain. + +### 6.3 Room state and count + +Presence is three-valued per room: + +| Evidence | Current room presence | +|---|---| +| Any valid positive Cartesian target strictly assigned to this room, or exact room-mapped positive occupancy/zone source | `on`, even if another selected source is unavailable; detail says partial if appropriate | +| Every configured evidence source provides an explicit negative under its own Stage-1 health contract | `off` = no presence detected, not proof of emptiness | +| No valid positive, and any required source is stale, missing, partial, unmappable or unable to assert a negative | `unknown`; never convert missing to `off` | + +Binary occupancy/zone inputs use the current HA on/off/unknown/unavailable and +bound-availability semantics from Stage 1; they do not receive a 3-second report +lease or a requirement for recurring state transitions. Coordinate `complete` +and report leases apply only to coordinate evidence/count. Thus a long-unchanged +but available exact room-mapped binary off can be valid negative evidence, +while an expired coordinate slot cannot. Raw zone counts remain diagnostic +inputs, not permission to infer a person count or fabricate complete geometry. + +Estimated count is an integer only when every configured count-contributing +source is eligible, complete and fresh, commissioning matches the epochs, no +unresolved candidate/boundary/handoff exists, and no source is at its maximum +reported target capacity. Then count the independent/confirmed-associated +observations assigned strictly to that room. Zero requires the same complete +negative evidence. Unsupported or uncertain count is `null`, not zero, and is +never inferred from occupancy or a distance. Count and presence have separate +availability: presence can validly be on while count is unknown. + +### 6.4 Handoff without identity + +A short-lived association token is an implementation detail, never a user or +person ID. It exists only in backend memory, expires after 2 seconds without +valid support and is reset at restart, gap or epoch change; it is never stored +in config, raw history, aggregates, HA attributes or exported diagnostics. + +Across adjacent allowed room polygons in the same space, a unique continuation +may keep the visual mark continuous only when the gap is ≤1 second, speed is +≤250 cm/s and the crossing passes a known door/passage or zero-thickness shared +boundary. No through-solid-wall or cross-floor handoff. A nonexistent opening +or unknown room topology means separate observations, not inferred movement. +Room count becomes unknown during a boundary-band or ambiguous handoff; do not +add one to both rooms or animate a line over a gap. A valid fresh positive +already inside the destination may establish its presence independently. No +long path joins and no promise that a reappearing mark is the same person. + +## 7. Optional native Home Assistant room outputs + +### 7.1 Explicit lifecycle + +**Create room sensors** previews each exact room, sources, proposed friendly +names, occupancy/estimated-count selection and these warnings: outputs are +derived/estimated, may become unknown, HA Recorder and external consumers may +retain their states independently, and no automations are created or changed. +The estimated-count checkbox is available only for an eligible commissioned +Cartesian group. Occupancy-only creation remains useful independently. + +Use integration-owned `BinarySensorEntity` with presence device class and +`SensorEntity` with integer native value, no invented device class, no +`total_increasing`/measurement statistics contract and no personal attributes. +They are push-updated only on semantic result changes, at most 1 state write/s +per entity. Unchanging coordinates do not force HA writes. Names use native +translation keys and the room label as a placeholder, not hardcoded English. + +Assign a random persistent output UUID at confirmed creation. Unique IDs are +`houseplan__radar_room__presence` and `_count`; +they never contain a mutable room name, source entity ID or array position. +Registry-chosen `entity_id` is returned after creation rather than promised +from a suggested slug. HA-owned user customizations are preserved. + +- Creation requires an unexpired user-bound preview plus current config/output + revisions, server revalidation and HA admin + `may_write`. At most 32 room + definitions / 64 native entities. Repeating the same operation ID is + idempotent; conflicting content with the same operation ID is rejected. +- Persist desired integration-owned outputs before registry reconciliation; + report `active`, `pending` or `failed`, not atomic success for a partial + platform error. Reconciliation only retries already explicitly authorized + definitions. Reconnect/retry/restart cannot create duplicate unique IDs. +- HA restart/unload starts values unknown/unavailable until current source + evidence arrives. Never restore last night's count as a current state. Normal + setup recreates runtime entity objects for authorized registry entries, not + new entities. Unload removes subscriptions/tasks; disabling a native entity + in HA is respected and House Plan never re-enables it. +- Changing a room name, decorative icon or source order preserves output UUID, + native entity ID and HA custom name. Changing input sources or room geometry + preserves identity but invalidates proof and temporarily suspends count. +- Deleting a source/room or losing a binding suspends the affected output and + shows Repair; do not retarget it or delete its HA registry entry implicitly. + Generic config writes/imports cannot grant or recreate output authorization. +- **Remove created entities** is a separate confirmed preview with exact owned + entity IDs and warning about dependent automations/dashboards. Known + references are shown when inspectable, never claimed exhaustive. Only the + entries matching this integration, config entry and recorded UUID are + removable. Source entities and unrelated same-name entities are untouchable. + Persist tombstones first; on partial failure retain a retryable pending + removal. A restart cannot resurrect a tombstoned definition. +- Turning off live display does not stop outputs. **Disable room processing** + stops computation and makes outputs unavailable, retaining identities. To + delete entities the administrator must use explicit Remove. Removing the + integration uses the normal HA config-entry lifecycle, never a custom sweep + of the user's registry or automations. + +### 7.2 Unknown versus unavailable + +Connected and functioning computation with insufficient/ambiguous evidence: +binary value `None` or sensor native value `None` with `available=True`, shown +by HA as `unknown`. Integration disabled/unloaded, coordinator failure or no +reachable configured input at all: `available=False`, hence `unavailable`. +Partial input loss with no positive gives unknown; a valid positive can keep +presence on, but cannot rescue an ineligible count. Keep only a short bounded +reason-code attribute; no coordinates, history, source slot IDs or timestamps +updated every frame. The native state must match the backend room snapshot. + +## 8. Optional heatmap: separate opt-in and bounded retention + +The issue explicitly requests local opt-in day/week heatmaps. Retaining coarse +aggregate cells for at most seven days is the bounded engineering interpretation +of that weekly mode. It is not an assertion that Q3 authorized seven-day raw +history or continuous collection: Q3's stage-2 raw TTL ≤24 hours and run ≤24 +hours remain unchanged. Coverage, fusion, reflection assistance and room outputs +do not depend on heatmap collection. + +Default: **off**, editor-only (`may_write`) manual opt-in for each bounded +session; default 1 hour, choices 5/15/60/1440 minutes, no run over 24 hours, no +automatic repetition, no resumed run after HA restart. Closing a browser does +not stop a consented server run. Starting a new session always requires the +visible retention notice and explicit action, even if an earlier session exists. + +The notice states: **Only aggregate cells, stored locally for up to 7 days. No +personal IDs or paths. Collection continues with this page closed until {end}.** +Provide current end time, Stop and separately confirmed **Delete heatmap data**. +Turning the display layer off does not stop collection; that difference is +stated beside Stop. Revoking an initiating user's current write privilege or +disabling the integration interrupts that run; it does not grant another +client ownership or auto-resume later. + +Minimal data contract: + +- Consume accepted current projected observations directly. A heatmap session + does **not** start or extend a stage-2 raw recording. Raw points, if separately + requested under stage 2, remain on their ≤24-hour TTL and separate controls. + No expired raw data is kept to permit later reaggregation. +- Grid: fixed 50 cm square cells anchored in canonical centimetre space; sparse + HA-local calendar-day buckets with explicit UTC start/end and timezone. + Persist only cell presence-observation seconds, collection + seconds, source-coverage completeness seconds, bucket date and a non-personal + geometry/source/filter epoch fingerprint. No target/track IDs, per-observation + timestamps, slot IDs or ordered paths. Multiple valid marks in one cell in + one sampling second increment presence by one, not by the number of marks. +- At most one sample per second; cap credit for any interval at 1 second. + Missing/invalid data contributes to missing coverage, never a continuation + of the last point. A cell outside known selected geometric coverage has no + denominator. Value is **Seconds with an observation / seconds with usable + coverage**, not percentage of time a named person spent there. +- UTC boundaries distinguish 23/25-hour local days without duplicate buckets; + day/week UI uses the saved HA timezone and explains actual included dates. + A timezone change closes the active session and starts no replacement; old + buckets remain under their recorded timezone. Buckets are deleted at age 7×24 hours based + on bucket start (conservative early expiry, never late). Sweep before every + read and on startup plus hourly; expired data is immediately unqueryable. +- Maximum 4 concurrent aggregate sessions, 32 radars total, 20,000 occupied + cell/bucket records per space and 32 MiB physical aggregate store total. + Bound admission before allocation. At quota stop affected collection with a + visible reason; do not silently evict nonexpired records or reduce resolution. +- Same cell contributions from selected overlapping sources are unioned per + second, not summed. A failed fusion does not justify a people heatmap: keep + the observation-based label. Calibration/source-generation/room/filter changes + close the current epoch; old epochs are separately selectable and never + silently reprojected or stitched. Require a new explicit session after such + a change. A changed plan scale cannot move historical bins without raw data. +- A week aggregates only actually consented sessions. Show recorded hours, + unavailable hours, calendar span and **Incomplete coverage**; no smoothing + across gaps, false seven-day completeness or conversion of calibration + samples into an automatically collected week. Restart truncates only the + active partial aggregation interval; completed saved buckets remain. +- Raw/aggregate stores are excluded from House Plan full/space/plan-only export, + support bundles, logs, diagnostics, telemetry and native HA attributes. No + cloud transport. Aggregate read APIs require `may_write`, as do raw APIs. +- Clear is revisioned, bounded and installation-wide for the explicitly selected + scope; cancel does nothing. Its confirmation explicitly says that matching + active aggregate sessions will stop. Persist that stop and a clear tombstone + in the same operation before deleting buckets. It deletes the matching aggregate buckets only, + not raw samples, calibration, zones, fusion or native entities. Stop is not + Clear. A tombstone prevents late flush/reconnect from recreating cleared data. + +House Plan TTL is not a claim about copies in operator-managed HA filesystem +backups or independent HA Recorder history of created entities. This limitation +must appear in the privacy help. House Plan backup/export never embeds these +stores; document how to exclude operational data from backup integrations that +offer such a hook, and never advertise deletion of external backup copies. + +## 9. Reflection-plane assistance: proposal, never automatic filtering + +This extends stage 2's manual reflector workflow, not firmware processing. +In one selected radar/epoch, an administrator marks at least 3 correspondence +pairs: a believed direct observation and its believed reflected observation. +Pairs must come from at least 3 spatial clusters separated by ≥75 cm, not three +samples of one stationary pair. At most 20 pairs; at least one pair is reserved +as a hold-out witness and does not fit the line. Thus acceptance requires at +least 4 pairs when exactly 3 fitting clusters are used. + +For the 2D mirror-line hypothesis, each pair's midpoint must lie on the proposed +line and its connecting vector be perpendicular. Fit the unit-normal line to +the fitting pairs; require reflected-point residual ≤20 cm for every fit and +hold-out pair, no degenerate <30 cm pair separation, and a common line direction +within 10°. Show residual and pair count, not a made-up confidence percentage. +The hypothesis is unsupported if these conditions do not hold. A successful +fit is still not proof of a physical mirror or that a removed point is false. + +An optional **Find candidate pairs** action may offer mutually time-matched +pairs (≤250 ms) from the selected retained stage-2 sample, with the same witness +rules. It is user-invoked, editor-only and bounded to 2,000 sampled observations, +20 proposed pairs, 100 tested line hypotheses and 2 seconds of work; on limit +return **Insufficient evidence** rather than taking the best weak candidate. +Never initiate recording or extend raw TTL. The administrator confirms each +correspondence; candidates are not applied automatically. + +Multiple real people, symmetric furniture, moving objects and multipath can +produce false positives. The result screen says so and shows both kept and +excluded sample points with the stage-2 side-of-line warning. **Use this line** +creates an unsaved stage-2 reflector draft; the separate stage-2 Save, current +config revision and exclusion preview remain mandatory. Cancel, expiry, +insufficient witnesses or changed epoch changes nothing. No unattended +background mirror search, hidden filter or HA source-state mutation. + +## 10. Persisted model, operational state and compatibility + +The stage-1 marker object stays `radar:{version:1,enabled,show_live,profile, +sources,mount,room_id,calibration}`; stage 2 adds `zones`/`reflectors`. Stage 3 +adds optional `allowed_room_ids` without renaming or repurposing those keys. +No personal tracking key is added to Marker or layout. + +Proposed additive global settings object: + +```text +settings.radar = { + version?: 1, + show_live: true, // existing common display preference, preserved unchanged + fusion_groups: [{id, enabled, space_id, marker_ids, commissioning}], + room_outputs: [{id, space_id, room_id, marker_ids, fusion_group_id?, presence, estimated_count}] +} +commissioning = {receipt_id, attested:true, algorithm_version, pair_quality, + checked_source_epochs, checked_config_fingerprint, distinct_frame_proof_summary} +``` + +Only newly created fusion-group and output IDs are immutable nonempty UUIDs; +existing space/room/marker references retain their exact current identifiers, +not a new UUID syntax requirement. An output's room must exist in its explicit +space, and all its selected markers/groups must belong to that same space. +Array ordering is presentation only. Optional `settings.radar.version` is an +additive stage-3 extension: absence remains valid, preserves the stage-1 +`show_live` behaviour and needs no load migration. A future unsupported extension +version makes only its stage-3 fields inert, not the independent stage-1 display +preference. The backend computes commissioning epochs/fingerprint/proof and +issues the receipt through §11; accepting arbitrary client-written proof is +forbidden. `attested` records the user's explicit exercise confirmation, not +sensor proof that a known number of real people were present. +`room_outputs` are definitions, **not authority to create HA entities**. A +separate versioned `houseplan.radar_outputs` Store retains explicitly granted +local output UUIDs, relevant config fingerprints, lifecycle revisions and +create/remove tombstones. Generic config edits/imports cannot manufacture that +grant. Do not persist live results as current truth across restart. + +Separate `houseplan.radar_heatmap` storage holds the opt-in run metadata, +coarse aggregates, revision and clear tombstones, with the bounds in §8. Raw +history remains solely the stage-2 bounded store. Analysis trial sectors, +pair-selection witnesses and fusion tokens are session/runtime memory only; +no `settings` autosave per frame. + +Register every new persisted path in `scripts/config-field-registry.mjs` with +absent/off defaults, consumer, delta-validation and transfer rule. Reads are +lossless and side-effect free. New/changed known values validate strictly and +atomically with `expected_rev`; untouched unknown/future fields survive +unrelated writes. Unsupported version is inert with a capability explanation, +not coerced to version 1. Do not bump the plan geometry model or rewrite old +files just to add optional radar state; new independent stores start at v1. + +Full same-instance config transfer retains definitions and stable IDs, but +never active collection or raw/aggregate contents. Restoring changed definitions +suspends mismatched output grants for explicit reconciliation. Foreign/full or +space import remaps internal marker/room/group references, drops out-of-scope +links with a preview count, invalidates commissioning and imports outputs as +inactive drafts with new local UUIDs. No automatic native-entity creation, +recording, resume or cross-instance authorization. Plan-only transfer includes +none of these source-dependent fields. + +Room/source deletion never maps by display name. Reconciliation suspends +affected outputs and prunes only runtime references; durable user definitions +remain repairable until explicit removal. Source-generation, calibration, +filter or room-boundary revision changes invalidate previews and epoch proof. +Merely moving the decorative icon or changing the theme does neither. +Use the common source-identity boundary: binding/unit/axis/owner-space changes, +physical mount x/y and the physical `installation_id` affect source generation. +Accepted heading/mirror corrections affect calibration revision. Explicit +**Change installation** creates a new installation UUID even at identical saved +coordinates. Stage 3 consumes these server-owned epochs rather than recomputing +them from a wider/narrower client field set. + +| Frontend/backend | Required behaviour | +|---|---| +| Old frontend + new backend | Ordinary stage-1/2 data survives; absent stage-3 controls do not enable anything; unauthorized config loss cannot erase active grants silently | +| New frontend + old backend | Absent fresh capability = no stage-3 writes or fake local-success fallback; stage-1/2 usable | +| New frontend + new backend | Backend revisions/epochs authoritative; identical results across clients and native entities | + +Before permanent downgrade, Stop/Clear optional collection and Disable/Remove +native outputs in a capable version. Older backend ignores new operational +stores but cannot enforce their expiry while absent: do not downgrade with +retained privacy data and promise continuing TTL. Re-upgrade purges expired +data before any read and never resumes collection. An older editor may drop +unknown fields if it reconstructs their parent; document this limitation and +preserve read-only backup before such editing. + +## 11. New WebSocket contract, consistency and budgets + +Proposed names are explicit new APIs, not existing endpoints. Advertise the +common fresh `radar_stage3_api:1` capability in `houseplan/config/get`, in addition +to the stage-1/2 capabilities. Cached/local config never grants a capability. Reconnect, +missing field or downgraded backend revokes it and stops requests. + +| Proposed command | Request / response and authorization | +|---|---| +| `houseplan/radar/analysis/preview` | `expected_config_rev`, selected marker/room IDs, ≤8 trial sectors → bounded coverage and proof/unknown reasons; `may_write`, memory-only user-bound token, TTL 60 s | +| `houseplan/radar/analysis/reflection_candidate` | Selected radar/epoch and bounded explicit witness IDs → line/residual/excluded preview; `may_write`, no mutation | +| `houseplan/radar/commissioning/inspect` | saved group ID, phase `single`/`separated_pair`, `expected_config_rev`, explicit capture request ≤30 s → bounded server-computed phase proof and user-bound token; `may_write` + every source read permission, no persisted recording | +| `houseplan/radar/commissioning/accept` | group ID, matching unexpired phase-proof tokens, explicit `attested:true`, current config revision → server-issued commissioning receipt via revisioned config transaction; same permissions rechecked; no client-supplied epochs accepted | +| `houseplan/radar/rooms/subscribe` | ≤32 exact `{space_id,room_id}` references → safe current `{server_session_id,seq,revision,config_rev,space_id,room_id,presence,estimated_count,reason,expires_at}`; authenticated plus all contributing-source read ACLs, no raw payload | +| `houseplan/radar/derived_entities/preview` | Explicit create/remove definitions + config/output revisions → exact owned IDs, operation diff and user-bound token; admin + `may_write` | +| `houseplan/radar/derived_entities/apply` | token, operation UUID and revisions → durable lifecycle revision plus per-output active/pending/failed states; same authorization rechecked | +| `houseplan/radar/heatmap/start` | exact source/room selection, duration, consent version, config/store revisions → run ID/end time; `may_write`; separate from raw recording | +| `houseplan/radar/heatmap/stop` | exact run ID and revision → stopped/interrupted metadata; `may_write`; idempotent | +| `houseplan/radar/heatmap/get` | bounded space/epoch/date range ≤7 days and cursor → ≤2,000 cells/page plus gaps/denominator; `may_write` | +| `houseplan/radar/heatmap/clear/preview` | exact space/epoch/date scope and store revision → matched bucket/run counts plus user-bound token; `may_write`, read-only | +| `houseplan/radar/heatmap/clear` | preview token, exact scope, expected revision, operation UUID → deleted scope/new revision; `may_write`, explicit confirmation | + +Room subscriptions inherit Stage 1's per-source ACL checks on every publish and +at least once per minute even without source events. If any contributing source +is restricted, withhold the entire room result as restricted; never silently +recalculate a different result from the visible subset or expose restricted +source IDs. Native entities retain the separately disclosed HA entity ACL. +Use the same server-session/monotonic-sequence and revision validation, clear on +disconnect/reconnect, bounded latest-wins delivery and immediate health/clear +transitions as Stage 1. Coordinate-derived room output expires no later than +its contributing coordinate leases, including transport age; browser expiry +clears that result if WS dies. Binary-only results use their native state health, +not coordinate leases, but still clear on connection loss or access revocation. + +Use existing config writes for saved lists/groups/reflector drafts, not a +second unrevisioned config path. Commissioning accept is a server-validated +operation using that same transaction helper and `expected_config_rev` barrier; +it is the sole writer of accepted proof metadata. Collection APIs use their independent store +revisions. Serialize config/operational lifecycle reconciliation; avoid a +half-written grant or clear-then-late-flush resurrection. A transaction fails +before mutation on stale preview, wrong user, lost privilege, revision/epoch +change, unavailable capability or quota. Return stable localizable reason codes +without raw points in errors. Exact retry is idempotent; reused operation ID +with different content fails `operation_conflict`. + +Analysis request ≤128 KiB; read/preview response ≤512 KiB; max 4 live preview +tokens/user and 16/installation, expiry 60 s. No auto-enlarged request to bypass +limits. Subscriptions coalesce to current snapshot, max 4 Hz and bounded one +pending snapshot/connection; slow clients receive current state instead of an +unbounded backlog. Unsubscribe, disconnect, page hidden and editor exit release +unneeded UI consumers; explicitly authorized backend outputs/runs continue. + +Performance witnesses use maximum accepted configuration (32 radars, four +groups, 256 live slots) and pathological concentrated points. Association +candidate work must remain inside §6 bounds; ≥60 seconds synthetic playback +must show bounded memory/queues. No analysis/heatmap module joins the initial +View graph; preserve `npm run bundle:budget`. On overload skip obsolete frames, +publish unknown with a reason, and recover without inventing intermediate data. + +## 12. i18n inventory (EN/RU) + +Frontend keys beneath `radar.stage3`; backend reasons use stable codes and +translated strings. Use the existing locale formatter for date/time, area, +distance and counts. These are new keys, not a claim they already exist. + +| Suffix | English | Русский | +|---|---|---| +| `tools` | Coverage and room results | Покрытие и результаты по комнатам | +| `coverage` | Indicative geometric coverage | Ориентировочное геометрическое покрытие | +| `coverage_hint` | Not a guarantee of detection | Не гарантия обнаружения | +| `coverage_unknown` | Coverage unknown | Покрытие неизвестно | +| `manual_estimate` | Manually estimated | Задано приблизительно вручную | +| `trial_add` | Add trial sector | Добавить пробный сектор | +| `trial` | Trial sector — not a connected device | Пробный сектор — не подключённый датчик | +| `allowed_rooms` | Allowed rooms | Разрешённые комнаты | +| `combine` | Combine observations | Объединять наблюдения | +| `ambiguous` | Observations cannot be combined reliably | Наблюдения нельзя надёжно объединить | +| `check_required` | Check this group again | Повторите проверку группы | +| `presence_on` | Presence detected | Присутствие обнаружено | +| `presence_off` | No presence detected | Присутствие не обнаружено | +| `count` | Estimated people: {count} | Оценка числа людей: {count} | +| `count_unknown` | Estimate unavailable | Оценка недоступна | +| `create_outputs` | Create room sensors | Создать датчики комнат | +| `remove_outputs` | Remove created entities | Удалить созданные сущности | +| `external_retention` | HA and other consumers may retain these states separately | HA и другие потребители могут хранить эти состояния отдельно | +| `admin_required` | Ask an HA administrator to create entities | Попросите администратора HA создать сущности | +| `pending` | Application pending | Применение ожидается | +| `repair` | Review the room and sources | Проверьте комнату и источники | +| `heatmap` | Heatmap | Тепловая карта | +| `heatmap_start` | Start aggregate collection | Начать сбор агрегированных данных | +| `heatmap_notice` | Aggregate cells only, up to 7 days; collection ends {end} | Только агрегаты по ячейкам, до 7 дней; сбор до {end} | +| `heatmap_incomplete` | Incomplete coverage | Неполные данные | +| `heatmap_clear` | Delete heatmap data | Удалить данные тепловой карты | +| `observed_seconds` | Seconds with an observation | Секунды с наблюдением | +| `usable_seconds` | Seconds with usable coverage | Секунды с доступными данными покрытия | +| `reflection_suggest` | Suggest a reflecting line | Предложить отражающую линию | +| `insufficient` | Insufficient evidence | Недостаточно наблюдений | +| `reflection_warning` | This can also exclude real targets | Это может исключить и настоящие цели | +| `reflection_use` | Use this line | Использовать эту линию | +| `quota` | Collection stopped: storage limit | Сбор остановлен: лимит хранения | +| `interrupted` | Interrupted; restart manually | Прервано; запустите вручную | +| `conflict` | Settings changed; refresh the preview | Настройки изменились; обновите предпросмотр | + +Add backend `entity.binary_sensor.radar_room_presence.name` / RU +`Присутствие — {room}` / EN `Presence — {room}`, and +`entity.sensor.radar_room_estimated_people.name` / RU `Оценка числа людей — {room}` +/ EN `Estimated people — {room}`. Translate `unknown`, `unavailable` through HA +native state semantics; do not output the English literal as a numeric value. + +## 13. Acceptance criteria and negative witnesses + +The named test files below are proposed implementation deliverables. No test +run is claimed by this docs-only task. Each protective AC requires the named +negative mutation/input to fail; a passing happy path alone is insufficient. + +| AC | Observable contract | Proof required | What must make the proof red | +|---|---|---|---| +| S3-1 | Opening/closing coverage does not change config, hardware or View; trial sectors are marked hypothetical | unit `radar-analysis`; smoke `radar_stage3` | Persist trial sector or invoke service from preview | +| S3-2 | Coverage distinguishes known/manual/unknown and never treats walls as RF simulation | unit geometry fixtures + golden | Fill unknown sector or include unknown room in area denominator | +| S3-3 | Explicit allowed list, empty list, missing/deleted room and boundary assignment follow §5 | backend + unit cross-language fixtures | Fallback from empty list; expand list after split/rename; assign boundary to two rooms | +| S3-4 | Coherent and bounded-latest branches retain source quality and combine only mutually unique candidates supported by distinct accepted frame/report witnesses | unit `radar-fusion` + backend independent-X/Y LD2450 replay | Remove time gate, count one frame three times, promote bounded_latest to coherent, reject all valid LD2450 estimates, or permit transitive/tied merge | +| S3-5 | Crossing/close people and missing/saturated sources preserve separate marks and unknown count | backend replay with labeled synthetic truth | Replace null with zero/sum or suppress ambiguous mark | +| S3-6 | Short handoff cannot create personal identity, gap path, cross-wall/floor link or double room count | unit + smoke source switch | Persist token; join through wall; count boundary twice | +| S3-7 | Native binary/count values match room snapshot, including partial input and unknown/unavailable; binary inputs keep Stage-1 state semantics | `test_ha_radar_room_outputs.py` | Treat missing as negative; expire unchanged binary on a coordinate lease; restore stale count; let UI count differ | +| S3-8 | Create/retry/restart creates exactly requested owned entities with stable IDs; rename preserves HA customization | HA harness lifecycle tests | Derive ID from room name; duplicate on retry; re-enable HA-disabled entity | +| S3-9 | Unauthorized/stale/replayed-different create/remove cannot mutate outputs or foreign entities; restricted sources cannot leak through room subscribe | HA WS permission tests + mutation | Remove admin/write/token/revision/ownership/source-ACL recheck; assert forbidden side effect or private canary | +| S3-10 | Room/source removal suspends; confirmed owned removal is retryable and never resurrects | HA crash/restart tests | Delete unrelated registry row; late reconciliation re-adds tombstone | +| S3-11 | Every heatmap run is separately consented, ≤24h, off by default, no restart resume or implicit raw collection | HA fake-clock + browser-close smoke | Auto-start on View/load/raw run; resume after restart; omit expiry | +| S3-12 | Raw ≤24h and aggregate ≤7d; quota, clear, epoch isolation and no history in exports/attributes enforced | backend storage/negative payload tests | Expired response, raw/ID in bin, post-clear flush, foreign epoch sum, support leak | +| S3-13 | Day/week exposes actual duration/gaps and observation-based metric without invented occupancy time | unit bucket/DST fixtures + golden | Fill gaps, multiply one cell by duplicate targets, show full week from 1h | +| S3-14 | Reflection proposal needs separated fit/hold-out evidence and explicit final Save; bad/symmetric data is not auto-filtered | unit geometry + smoke | Remove witness/residual guard or apply proposal automatically | +| S3-15 | Bounds hold at max config and dense adversarial targets; queues stay bounded and View remains responsive | backend perf replay + `radar_stage3` stress smoke | Remove candidate/page/quota cap; grow pending queue | +| S3-16 | Capability downgrade and import preserve stage-1/2 behaviour, never create entities/restart collection or mint commissioning from client claims | backend round-trip + mixed-version/commissioning smoke | Trust cached capability; import active grant/run/proof; accept forged/duplicate-frame proof; erase unrelated siblings or reject inherited non-UUID room IDs | +| S3-17 | View/kiosk gestures and safe actions work on desktop/touch; no history/editor leakage or diagnostic hit interception | targeted smoke + golden light/dark 360/736/1024 | Overlay intercepts pointer; hidden privileged controls dispatch writes | +| S3-18 | EN/RU labels preserve estimated/unknown/hypothetical meaning and entity names remain translated | i18n parity + browser/native registry assertions | Raw key, hardcoded English name, unqualified “people” count | + +## 14. Test plan, risks and release evidence + +Unit fixtures: disjoint/missing room polygons; exact boundary band; rotated +calibration; zero coordinates; timestamp skew; 2/3-source clique versus chain; +two real close targets; crossing; slot reuse/saturation; source epoch churn; +handoff at door, solid wall and different floor; reflection fit/hold-out failures; +cell union, midnight/DST, TTL, quota and clear tombstones. Share normalized +fixture data between frontend projection and backend, but independently assert +expected values so two copies of the same wrong formula cannot agree unnoticed. + +HA tests require the real Linux/WSL harness: platform setup/unload, registry +rename/customization, disabled entity, auth downgrade, restart mid-create/remove, +store failure, source loss and recovery, config conflict, import, quota, +expiry-before-read and pending-flush-after-clear. Pure native-Windows tests do +not prove HA lifecycle. No household CSV is committed or used without explicit +permission; use synthetic fixtures plus separately consented, local-only field +validation of count eligibility before enabling it for a connection profile. + +Proposed commands after implementation: `npm run typecheck`, `npm test`, +`npm run build`, `npm run bundle:sync`, `npm run bundle:budget`, +`node scripts/no-new-any.mjs --base origin/dev --head HEAD`, +`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, +`node demo/smoke_radar_stage3.mjs`, `python -m pytest tests_backend -q` in +the real HA harness. Add backend targeted files to the relevant selector and +required mutation witnesses to `scripts/mutation-gate.mjs` for protective AC +covered by expensive gates. Exact command/output and skipped checks accompany +handoff; do not report proposed commands as passed. + +Risks: false duplicate suppression/undercount, multipath mistaken for a mirror, +unequal source clocks, geometric calibration drift, gaps hidden by aggregation, +native registry partial failures and private-data copies outside House Plan. +Mitigations are respectively conservative fallback, manual acceptance, temporal +ineligibility, epoch invalidation, explicit denominators, durable reconciliation +and honest retention boundaries. Count is informational, never a safety/security +occupancy guarantee. No automation action is bundled with it. + +Release artifacts for actual implementation: `docs/CHANGELOG.md` and +`docs/CHANGELOG.ru.md`; `docs/USER-GUIDE.md` and `docs/USER-GUIDE.ru.md`; +update `docs/ARCHITECTURE.md`, +`docs/CONFIG-COMPATIBILITY.md`, field registry and privacy/support projection +documentation. Capture representative actual coverage, ambiguous overlap, +unknown native output and incomplete heatmap, light/dark +and narrow/touch View. Use approved complete Linux CI documentation/golden +artifacts and reviewed acceptance, not the conceptual UX sketch as a screenshot. +Attach bounded-performance, permission/leak, native lifecycle and protective +mutation results. Beta/RC at an exact green CI SHA precedes stable promotion. +No version, shipped changelog, generated artifact or release is changed by this +specification-only task. + +## 15. Rollback and assumptions + +Operational rollback: disable fusion groups to return to independent stage-1/2 +marks; restore single allowed room explicitly if desired; disable a saved +reflector through stage 2; Stop/Clear aggregates; Disable or explicitly Remove +native outputs. Hiding live display alone is not rollback of background work. +No rollback sends source services or edits existing HA automations. Preserve +config definitions for repair unless the user explicitly removes them. For +binary/code downgrade follow §10's privacy purge and entity suspension steps; +do not use a browser Labs flag to gate backend data, storage or permissions. + +**Technical choices assumed, change freely in review:** module/API names, +independent Store layout, sparse grid representation, UUID spelling, preview +TTL, operation-journal implementation, candidate thresholds and quotas. Reviewer +may change these with equivalent bounded tests and conservative semantics. +They are not additional questions for the owner. + +**Bounded weekly-mode assumption:** §8's local seven-day aggregates implement +the explicitly requested weekly view, while every manual session remains ≤24h. +Neither the weekly selector nor a prior consent authorizes continuous multi-day +runs, raw retention changes or automatic renewal. This is recorded as an +engineering interpretation, not an additional claim about the wording of Q3. +No open owner question remains in this stage; independent review still decides +readiness of the complete specification package. diff --git a/docs/specs/485-radar-presence.md b/docs/specs/485-radar-presence.md new file mode 100644 index 00000000..8b56afbd --- /dev/null +++ b/docs/specs/485-radar-presence.md @@ -0,0 +1,244 @@ +# #485 — Presence radars on the plan: common acceptance contract + +Issue: [#485](https://github.com/Matysh/houseplan-card/issues/485). +Author: Codex. Baseline: `dev` at +`ed9ee026dc08054e12038b7dbd1b8525e7706d04`, 2026-09-08. +This is a specification, not shipped behaviour. Status is exclusively the issue +label. Current owner instruction: obtain independent specification approval, +stop at **S5-ready**, and do not implement or proceed to S6. + +## 1. Reading order and authority + +The acceptance package consists of this common contract and all three documents: + +1. [Stage 1 — source model, calibration and live presence](485-radar-presence-stage1.md). +2. [Stage 2 — zones and bounded observations](485-radar-presence-stage2.md). +3. [Stage 3 — coverage, aggregation and derived room states](485-radar-presence-stage3.md). + +The stages are dependent implementation increments, **not** three ways to narrow +the issue to its easiest part. The independent S4 review covers all four files; +S5 means the complete package is ready. Later implementation/release reports +must name exactly the stages delivered. No unfinished stage disappears because +the live layer works. Creating follow-up issues, changing the agreed scope or +closing this issue before all its AC are met requires an owner decision. + +Authority: `docs/SCOPE.md`, repository process, the issue's owner request, +[UX proposal](https://github.com/Matysh/houseplan-card/issues/485#issuecomment-5583682816), +[analysis](https://github.com/Matysh/houseplan-card/issues/485#issuecomment-5583878376), +and [accepted defaults](https://github.com/Matysh/houseplan-card/issues/485#issuecomment-5583909140). +The numbered specs turn those requirements into testable contracts. Old open +questions in the original issue are superseded by these explicit decisions; +the external field report is evidence, not an instruction to copy a firmware. + +## 2. Product intent and boundaries + +Primary job: J1, understand presence **now**, on a familiar floor plan. The home +administrator configures the connection once; household members, phones and +wall kiosks see unobtrusive current observations. J4/J6 support safe setup and +diagnostics, not a new technical console in ordinary View. + +The owner explicitly requested zones, local observations, day/week heat maps +and optional derived HA entities in #485. These are a bounded exception to +SCOPE's exclusion of general historical analytics: only spatial radar setup +and occupancy analysis, local and opt-in. This does not authorize general +time-series charts, an automation editor, tracking named people or cloud upload. +Original HA entities, automations and existing light Glow/spill remain untouched. + +| Accepted decision | Consequence | +|---|---| +| Q1 default | Live marks appear after explicit configuration; global and per-radar switches hide them. No hover prerequisite. | +| Q2 default | Stages 1–2 use one selected room if its polygon exists. Stage 3 permits multiple allowed rooms. No invented room rectangle. | +| Q3 default | Stage-2 raw recording starts manually, defaults to 1 h, each run <=24 h, each point expires after 24 h, works without a browser, editor-only access. | +| Q4 default | Cross-radar consolidation is Stage 3; earlier stages make no global people count or identity claim. | + +The requested weekly heat map has its own explicit, default-off collection +action; it is not enabled by raw recording or live display. Each collection +authorization still lasts at most 24 h, without automatic renewal. Stage 3 +retains local aggregate bins for at most seven days, not seven days of raw +trajectories; a week can contain gaps or separately authorized sessions. This +is the bounded engineering interpretation of the owner's day/week requirement, +not an assertion that Q3 authorized permanent collection. + +## 3. One feature, several capabilities + +There is no new editor mode or separate fleet of person/device markers. Add +configuration under the existing device/placed-entity marker in Device editor: +**Presence on plan**. Reuse the primary House Plan panel and full dashboard +card's shared editor. The compact Room View card is not a second radar editor; +this issue neither replaces its current renderer nor adds historical tools to it. + +A device label/manufacturer is never proof of data availability. The backend +derives capabilities from exact bound entities and a verified profile: + +| Source evidence | Honest presentation | Forbidden inference | +|---|---|---| +| Valid local X/Y or explicitly described polar pair | One dot per current target slot | Stable person identity from a slot index | +| Range without bearing | Arc of possible positions; no claimed angle | Turn two distances into X/Y or two people | +| Occupancy/count for a known zone | Zone outline/state; Stage 2 provides manual polygons | Invent coordinates or remotely editable FP2 geometry | +| Presence only | Existing presence marker and optional diagnostic coverage | A fabricated person position or occupied area from marketing range | + +Reference adapters: ESPHome LD2450 (coordinate slots and separately verified +zone-number bindings), generic explicit Cartesian/polar/range/occupancy inputs, +and exact zone occupancy/count mappings (including FP2-style connections). +Named products in the initial issue are examples, not a claim that every +firmware is automatically supported. Unknown connections use manual bindings +or an unsupported explanation, never guessed axes/units/writable capabilities. + +## 4. Shared architecture and authoritative state + +Existing seams: `src/types.ts`, `src/config-store.ts`, +`src/space-geometry.ts`, `src/houseplan-editor-runtime.ts`, +`src/houseplan-card.ts`, `src/render-device-snapshot.ts`, +`src/houseplan-render-lifecycle.ts`, `src/live-viewport.ts`, +`custom_components/houseplan/{__init__,store,auth,validation,websocket_api}.py`. +New pure radar modules, a lazy editor and backend coordinator are specified in +the stage documents. Do not duplicate the giant card's rendering/gesture logic. + +Backend coordinator owns source normalization, freshness and room/filter +classification. One coordinator per integration, not per browser. Stage 2 and +3 consume its frames, never independently infer availability or reparse HA +values. Browser projection helpers used for calibration must agree with the +backend on shared numeric fixtures. Backend sampling never relies on UI frames. + +One persisted optional namespace: `marker.radar.version=1` and +`settings.radar`. Common settings start with `show_live` (absent => true); +per-radar `enabled` requires explicit setup, absence means no radar operation. +Stage 2 extends radar with `zones`/`reflectors`; Stage 3 extends it with +`allowed_room_ids`, and `settings.radar` with fusion/room-output configuration. +Capabilities `radar_stage1_api:1`, `radar_stage2_api:1`, `radar_stage3_api:1` +are additive advertised runtime features, not stored user toggles. + +Server-owned runtime identities: + +- `source_generation`: opaque fingerprint/version of exact source bindings, + mount x/y, installation UUID and coordinate conventions. It changes on a + physical move, source/unit/axis change or owner-space change, not decorative + icon drag. Explicit Change installation creates a new installation UUID even + if the saved position is unchanged. Heading/mirror/reference correction alone + changes calibration revision, not source generation. +- `calibration_revision`: accepted projection correction within that same + installation. Room/filter/zone revisions are separate evaluation epochs. +- Frame `seq` and `server_session_id`: monotonic within one coordinator session; + clients discard older/out-of-session deliveries and clear on reconnect. + +Runtime ids, consent runs, histories, command tokens and telemetry are not +portable configuration. All config writes keep the existing `expected_rev` +transaction and `may_write` policy. New endpoints do not provide a generic +service proxy. HA entity permissions are additionally respected when reading +or controlling entity data; House Plan write access is not permission elevation. + +## 5. Shared lifecycle / compatibility obligations + +Optional additions do not change global config version or rewrite old plans on +read. Validate new/changed known blocks; preserve untouched malformed/future +blocks and unknown siblings losslessly, but do not execute them. Never coerce +an invalid radar into a valid-looking coordinate at `(0,0)`. + +New frontend/old backend: preserve config, display update-required explanation, +make no unsupported calls and hide unsupported layer/tools. Old frontend/new +backend: additive fields survive the existing unknown-field path; clients which +reconstruct whole markers are not promised safe editing after downgrade. The +documentation recommends read-only downgrade or current-version backup first. + +The implementation must cover the existing full backup, config import/export, +plan-only export, space duplicate/import/remove, room removal, marker movement +between spaces and Optimize transactions, not just config/set. Stage documents +define which references survive/remap. Full restore must retain exact configured +refs but starts **no recording, device write or entity provisioning** implicitly. +Duplicate space does not bind a copied radar to live hardware by accident: +the existing duplicate virtualization policy also removes its radar bindings. + +Disabled/removed/missing space or room is not permission to choose the first +remaining space/room. Invalidated observations disappear; repair UI is explicit. +Geometry-preserving Optimize keeps valid projections unchanged. A coordinate +frame/physical-scale change invalidates calibration and suspends affected live +projection until confirmed recalibration, rather than shifting observations +silently. Pure pan, zoom, theme, fit-to-room, plan wallpaper transform and moving +the decorative icon do not alter physical calibration. Room shape changes +reevaluate membership, invalidate analysis epochs and affected room outputs. + +Raw observations, aggregates and radar runtime snapshots are excluded from +House Plan support packages/portable exports/logs/browser persistent storage. +Full administrator HA backups may independently capture integration storage; +the UI discloses that these copies are outside retention/deletion guarantees. +No new cloud, telemetry, CSV upload or public download URL is introduced. + +## 6. UX, visual and accessibility invariants + +- View is quiet: small marks, no person names, trails, debug sectors, heat maps + or editing handles unless explicitly entering setup/analysis. No new pulsing + background competes with light Glow. Reduced motion disables interpolation. +- Radar dots/arcs/fills are pointer-transparent. Existing device tap_action, + full capsule hit area, room-fit, double-tap fit and pan/pinch retain priority. +- Live layers are hidden in Plan/Background editors. Device setup shows only + the selected radar's diagnostic geometry. Stage-3 analysis is a deliberate + contextual surface, not a fourth permanently visible editor tab. +- No rewrite of isometric projection: reuse the existing projection seam for + floor-level live marks; never render a second unprojected floor layer. Precise + calibration/analysis use 2D temporarily and restore the user's view on exit. +- View/kiosk fully support touch (`docs/TOUCH-SUPPORT.md`), including resume, + orientation changes and no-hover paths. Editors remain desktop-first, with + safe cancellation/permissions on touch. Minimum essential action targets + 44 px; long labels wrap and dialogs retain visible Close/Cancel controls. +- Keys/text are specified en+ru in each stage, escaped as text. Locale number, + length and date formatting is reused. No coordinate speech on every frame; + meaningful health changes are debounced polite status text. + +## 7. Acceptance map and completion evidence + +All stage AC are required in addition to these cross-cutting ones. Test paths +named in this package are planned deliverables, not tests claimed as run. + +| AC | Required outcome | Proof / negative witness | +|---|---|---| +| C-1 | Four capability classes produce only evidence-supported output | unit/backend state matrix; remove capability gate => invented point test fails | +| C-2 | Setup opt-in; display toggle does not start/stop hidden collection | smoke + backend command spies; auto-start on View/toggle => zero-start assertion fails | +| C-3 | One normalized source authority across clients/recording/entities | backend two-client/recorder fixture; stale or duplicated frame injection changes expected set and fails | +| C-4 | All lifecycle seams in §5 preserve or explicitly invalidate refs without retargeting | backend import/duplicate/Optimize/removal + unit preservation tests; drop guard => wrong-space canary fails | +| C-5 | No regression in devices, Glow, room actions, gestures or isometry | View/kiosk smoke and reviewed golden full scenes light/dark, 2D/isometry; layer hit-test mutation => click/pan failure | +| C-6 | No raw/aggregate leakage or implicit external writes | backend permissions/support/export + browser persistence/network canary checks; include private field/skip permission => failure | +| C-7 | Optional/unknown/future fields and rolling frontend/backend matrix remain safe | unit/backend roundtrip/API trace; remove capability gate/unknown preservation => failure | +| C-8 | Full package is independently reviewed; no implementation under current instruction | review of spec links, exact branch SHA and diff restricted to documentation; S5 label, not author self-approval | + +Protective AC require named negative witnesses and failing output at code review, +per PROCESS §2.7. Expensive witnesses belong in `scripts/mutation-gate.mjs`; +text-regex tests or a single happy-path screenshot cannot prove geometry, +authorization or retention. TS/Python must consume identical coordinate fixtures. + +Implementation loop: typecheck, unit, build. Targeted checks may diagnose a +failure; complete golden/smoke/performance/security and full HA backend harness +are pre-beta evidence on exact SHA in Linux CI/approved WSL, not native Windows +claims. Existing initial-bundle gzip ceiling **256,000 bytes** remains binding; +lazy editors/analysis cannot enter initial View graph. Stage documents add caps. + +## 8. Release, rollback and risks + +Future behaviour commits include `Issue: #485`, `User-Visible: yes`, both +`docs/CHANGELOG.md` and `docs/CHANGELOG.ru.md`, user guide EN/RU, architecture, +compatibility/field registry and status updates. Review complete synthetic +golden scenes, not only crops; include compact phone, wall tablet and desktop, +light/dark and representative live/error states. Record baseline/candidate +performance and security/retention/mutation tables. A beta is issued only on +owner command; current request creates no beta, code or version bump. + +Rollback: hide live layers for visual rollback; disable affected radars to stop +runtime processing; Stop/Clear private recordings/aggregates with the current +version before downgrade. Removing optional derived HA entities is explicit +and warns about downstream automations; hardware-zone rollback is another +confirmed device operation, never automatic. Keep portable config backups. + +Risks: coordinate ambiguity, suppressed unchanged HA reports, optimistic device +echoes, overconfident counts, reflection false positives, source reconfiguration, +privacy/storage growth and duplicate browser work. No specification can make +an uninformative sensor report true position; degraded states are a necessary +part of the feature, not defects to conceal. + +## 9. Explicit engineering assumptions + +The owner resolved Q1–Q4. Module names, bounded resource/timing constants, +protocol version numbers, conservative confidence thresholds and test filenames +are reviewable engineering choices. Seven-day aggregate expiry is the minimum +window for the explicitly requested weekly view, with independent <=24 h opt-in +sessions and visible gaps, not a continuous-history product expansion. Reference +adapter firmware evidence is linked in stages; user-supplied private recordings +are not prerequisites or public fixtures without contributor permission. diff --git a/docs/specs/README.md b/docs/specs/README.md index 5c102665..3d8f07d1 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -102,6 +102,7 @@ |---|---| | [#493](https://github.com/Matysh/houseplan-card/issues/493) Ограниченный picker и надёжный lifecycle сводной панели | [493-summary-panel-hardening.md](493-summary-panel-hardening.md) | | [#437](https://github.com/Matysh/houseplan-card/issues/437) Конфигурируемая сводная панель поверх плана | [437-summary-panel.md](437-summary-panel.md) | +| [#485](https://github.com/Matysh/houseplan-card/issues/485) Радары присутствия: общий контракт и три этапа | [Общий контракт](485-radar-presence.md) · [Этап 1](485-radar-presence-stage1.md) · [Этап 2](485-radar-presence-stage2.md) · [Этап 3](485-radar-presence-stage3.md) | | [#487](https://github.com/Matysh/houseplan-card/issues/487) Пороги комфортной температуры для комнаты | [487-room-temperature-thresholds.md](487-room-temperature-thresholds.md) | | [#478](https://github.com/Matysh/houseplan-card/issues/478) Отказ от persisted-сущности `room_drafts` | [478-remove-room-drafts.md](478-remove-room-drafts.md) | | [#477](https://github.com/Matysh/houseplan-card/issues/477) Fixed point оптимизатора после штатного редактирования | [477-editor-writer-fixed-point.md](477-editor-writer-fixed-point.md) |