docs: specify radar presence stages for #485

Issue: #485
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-09 03:50:50 +03:00
committed by Matysh
parent 3434747d1c
commit 78afb68962
5 changed files with 2278 additions and 0 deletions
+534
View File
@@ -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.
+700
View File
@@ -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<x2` and `y1<y2`. Mode and all slot values are
shown in the confirmation, including unchanged slots affected by a full-set
device write. Clearing all slots requires Unrestricted; do not install an
empty Detection set by accident.
Saving House Plan polygons/reflectors changes shared plan configuration only.
Applying hardware zones is a separate command with a warning that the radar's
detection and existing HA automations may change. Saving a name, moving the
decorative device icon or importing config never sends device commands.
### 4.2 Reflection filters
Advanced → False reflections → Add reflecting surface. The user draws a
segment, names it, and sees the opposite half-plane relative to the physical
sensor mount highlighted. The complete line through the two endpoints is the
filter boundary; the endpoints are handles defining that line, not a promise
that the effect stops at a mirror's visible ends. Text explicitly states that
real targets behind the line may also be hidden. The line is a House Plan
filter, not furniture, a wall, or a claim about electromagnetic propagation.
The same canonical membership predicate drives live preview, cloud exclusions
and saved filtering. Points strictly on the opposite side are excluded; points
on the line within 0.1 cm are retained. A sensor on/within 1 cm of the line,
or coincident endpoints, is invalid. Multiple enabled lines use OR exclusion.
Room clipping remains the stage-1 single-room filter. Outside-room points are
out of the chosen display area, not declared physically false. Missing room
geometry never produces a guessed boundary.
Preview is reversible and has zero HA side effects. Apply saves only local
filters; the original HA presence entity and upstream automations are unchanged.
Raw observations are retained before filtering. “Show excluded points” uses
grey crosses and reasons `outside_room` / `reflector:<id>`; 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.
+799
View File
@@ -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_<config_entry_id>_radar_room_<output_uuid>_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.
+244
View File
@@ -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.
+1
View File
@@ -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) |