Files
houseplan-card/docs/reviews/CODE-REVIEW-485-r1.md
T
2026-09-09 03:52:22 +03:00

35 KiB
Raw Blame History

CODE-REVIEW-485-r1

Issue: #485 — presence-radar Stage 1. Этап: code (PROCESS.md §2.7). Заход r1, блокирующих циклов израсходовано 0 из 4 до этого раунда.

Материал: ветка issue/485-radar-presence, git log --oneline origin/dev..HEAD / git diff origin/dev...HEAD, HEAD = f9acaa35 ("build: integrate radar with current dev"). Диапазон коммитов: 5e1d7f03..f9acaa35 (spec-review документы + 578df257 feat, 8e03dec7 chore, 4aacf8a4/5b975310/032435dd/5019eae5 fix/test, f9acaa35 build). Диапазон затрагивает 113 файлов, +10832/-732 строк. Реализационная authority — docs/specs/485-radar-presence-stage1.md (проверена целиком; общий контракт 485-radar-presence.md использован для терминологии Stage 2/3-полей).

Скоуп

Первый раунд — предыдущих код-ревью нет, разбор полный. Раздел «Унаследовано» не пишу — наследовать нечего.

Проверено: полный бэкенд (radar.py, radar_geometry.py, radar_validation.py, radar_websocket.py, диффы __init__.py/store.py/validation.py/ websocket_api.py/import_export.py), весь новый фронтенд (radar-model.ts, radar-geometry.ts, radar-live.ts, radar-render.ts, radar-setup.ts, radar-editor.ts, editors/radar-section.ts, дифф houseplan-editor-runtime.ts /houseplan-card.ts/types.ts/стили), i18n (en/ru/de/fr), все новые тесты и смоки, docs/RADAR.md, дифф USER-GUIDE.{md,ru.md}, docs/SCOPE.md, changelog, config-schema/field-registry.

Как проверялось

Разбор вели три параллельных агента (бэкенд-код, фронтенд-код, тест-качество+доки) с конкретными пунктами спецификации для сверки построчно; самые серьёзные и неожиданные находки перепроверены мной лично чтением исходников (см. ниже) — «доверяй, но проверяй» применён к каждому High.

Прогнанные гейты и почему:

  • npx tsc --noEmit, npm test, npm run build (со сверкой бандла) — не перегонял: Validate на f9acaa35 зелёный (https://github.com/Matysh/houseplan-card/actions/runs/34262041971), это дешёвые гейты из уже подтверждённого прогона. Инцидентально npm run build всё же выполнялся мной (см. ниже про golden) и прошёл чисто, что попутно ещё раз подтверждает tsc.
  • node --test test/radar-editor.test.mjs test/radar-geometry.test.mjs test/radar-model.test.mjs test/radar-setup.test.mjs — прогнал (через агента): 22/22 pass.
  • python -m pytest tests_backend/test_ha_radar.py tests_backend/test_ha_radar_websocket.py tests_backend/test_radar_geometry.py tests_backend/test_radar_validation.py -q (Python 3.14 venv, т.к. в песочнице по умолчанию 3.12, а pytest-homeassistant-custom-component==0.13.357 требует ≥3.14) — прогнал (через агента): 81/81 pass; дополнительно pytest tests_backend/ -q --collect-only — 799 тестов, ошибок импорта нет. Полный прогон всех 799 backend-тестов не проводил: это уже покрыто зелёным Validate на этом же SHA (backend job гоняет полный tests_backend), а collect-only исключает поломку импортов от новых модулей.
  • node demo/smoke_radar_setup.mjs и node demo/smoke_radar_live.mjs — прогнал лично: оба OK (все булевы поля отчёта true).
  • npm run golden:verify — прогнал лично (после npm run bundle:sync, без которого стенд demo/srv/assets не синхронизирован и тест падает на Failed to fetch dynamically imported module — это issue-независимая ловушка окружения, не находка). Результат: 119 из 150 сцен «different». Поставил контрольный прогон той же командой на чистом origin/dev (git worktree, 586494b7) — тот же набор сцен «different» с теми же именами в том же порядке. Это подтверждает: расхождения — окруженческий шум (рендер/шрифты этой песочницы против CI-снятых эталонов), не регрессия этой ветки. Единственная осмысленная находка из golden — не сами расхождения, а их отсутствие для радара: в demo/golden/baselines/ нет ни одной сцены со словом «radar» (проверено find/grep) — при том что S1-1 и S1-19 называют «en/ru light/dark golden» и «device-editor smoke/golden for coordinate/range/ zone/presence radars... light/switch/temperature/PIR/virtual/unknown devices and saved broken/future config» ровно доказательством по этим AC. Обещанный способ доказательства отсутствует целиком, не частично.
  • node scripts/check-docs.mjs — по умолчанию (strict) падает: «screenshot source fingerprint is stale». Это ожидаемо и не находка: scripts/ docs-freshness.mjs документирует, что strict — это режим кандидата беты и релизного гейта, а обычный пуш использует --screenshots=warn; с warn проверка проходит чисто (7 файлов, 12 внешних ссылок). Раз диффа src/** тут неизбежно делает отпечаток «устаревшим», и обновление отпечатка — задача отдельного docs: refresh screenshot fingerprint коммита перед бетой, а не этого раунда.
  • npm run invariants — не запускал: diff не трогает рёбра комнат, записи толщины, layout, marker.space, open_spans (проверено grep по всему дифф-списку файлов и по тексту radar*.py/radar-*.ts — совпадений нет). radar.room_id — ссылка на существующую комнату, но геометрия комнаты не меняется и не создаётся радаром.
  • Полный demo/smoke_*.mjs матрикс — не гонял. node scripts/smoke-select.mjs --base origin/dev --head HEAD вернул 2 «прямых совпадения» именно на радар-символы (smoke_radar_setup.mjs ← _radarSetup, smoke_radar_live.mjs ← _haRadarStage1Api,_radarLive,_syncRadarLive) — оба прогнаны выше и зелёные. Остальные ~53 «прямых» и ~52 «слабых» совпадения инструмент относит к широким символам (_editorRuntime, _markerDialog, _mode, _cfgRev, cellCm), закономерно задетым любой правкой в общем диалоге маркера — порог «широкого» символа инструмент сам поднял до 46 смоков. Конкретные находки ниже получены чтением кода, а не через смоки; прогон полной матрицы не добавил бы уверенности сверх уже найденного и является предрелизной обязанностью (PROCESS §8), не обязанностью этого раунда.

Находки

High (блокируют)

H1 — «partial» не существует как состояние здоровья; смешанные и полностью устаревшие данные неотличимы. custom_components/houseplan/radar.py, сборка frame["health"] (~540–543): elif any_stale: frame["health"] = "stale" срабатывает и когда устарел один слот из трёх, и когда устарели все. Конкретный сценарий: 3-слотовый cartesian_v1 радар, один слот — свежая валидная цель, два — просрочены, occupancy=True. frame["targets"] не пуст → ветка position_unavailable пропущена → health="stale", неотличимо от радара, где абсолютно все данные устарели. Спецификация (§9, AC S1-5) требует, чтобы off/unknown/stale/ partial/inconsistent/occupied-without-position были «genuinely distinct states in code, not collapsed» — это прямое нарушение центрального для всей задачи принципа честности статуса измерения (§4). Ни один текущий тест не покрывает смешанный много-слотовый сценарий.

H2 — гонка teardown/await воскрешает HA-подписки после выгрузки интеграции. custom_components/houseplan/radar.py, async_setup()/async_refresh() (104–139). async_refresh() проверяет self.closed один раз до await self.runtime.config_store.async_load() и не перепроверяет после возврата из await; teardown() (141+) — синхронный метод, не берёт self._lock. Сценарий: houseplan_config_updated планирует async_create_task(self.async_refresh()); задача приостанавливается на await; параллельно интеграция выгружается — teardown() отрабатывает полностью (self.closed=True, _unsub_sources очищен); задача возобновляется и без повторной проверки self.closed вызывает self._resubscribe_sources() (регистрирует новые async_track_state_report_event/ async_track_state_change_event) и self._publish_all(force=True). Эти новые HA-слушатели никогда не будут сняты — объект-координатор уже выброшен async_unload_entry (__init__.py:299-301), никакой будущий teardown() до них не доберётся. Это ровно сценарий, который спецификация запрещает явно (§7: «Teardown checks after awaits prevent resurrection on unload», AC S1-6). Тот же паттерн — во втором await внутри async_setup().

H3 — нет проверки «то же устройство» для верифицированного адаптера LD2450. custom_components/houseplan/radar_validation.py нигде не читает device_id/реестр устройств (grep по файлу и по radar.py — ноль совпадений); сигнатуры валидаторов не принимают hass/снимок реестра. Сценарий: черновик {profile: "esphome_ld2450_v1", unit: "mm", x_entity: "sensor.kitchen_temperature", y_entity: "sensor.unrelated_integration_humidity"} — два датчика с разных устройств/интеграций — проходит _validate_sources и принимается как «верифицированный ESPHome LD2450», включая специфичную только для этого адаптера семантику «X=0,Y=0 = явное отсутствие» (radar.py:586-587), на которую он права не имеет. Прямое нарушение §3 («require the same ESPHome device») и §7 («Same-device checks remain mandatory for verified hardware adapter roles, not generic input»).

H4 — «Настроить на плане» отказывает для комнаты без контура вместо предупреждения и незанятой настройки. src/editors/radar-section.ts:175-185:

if (!space || !room || !Array.isArray(room.poly) || room.poly.length < 3
    || !radarConfigFromDraft(radar, space.cellCm || 5)) {
  options.toast(options.t('radar.invalid'));
  return;
}

Комната «есть, но без контура» (poly.length < 3) обрабатывается идентично «комнаты нет вовсе» — мастер настройки вообще не открывается, только общий toast об ошибке. Спецификация требует обратного: «A room without contour warns "No room boundary: points are not clipped"» (§5.2, шаг 1) и «existing room with no usable contour allows unclipped calibrated output with the stated warning» (§5.3, AC S1-11: «missing contour warns, missing room never retargets»). Подтверждено также, что строки radar.no_contour нет ни в одном из четырёх словарей i18n — предупреждение не реализовано вообще, не только не показано в этом месте. Целый класс валидных Stage‑1 установок (радар, смотрящий в комнату без нарисованного контура) недостижим через UI.

H5 — известные занятые зоны (zones_v1) никогда не рисуются на плане. grep -n "zone" src/radar-render.ts — ноль совпадений; renderRadarLive обрабатывает только frame.ranges и frame.targets. RadarLiveFrame.zones нормализуется (radar-model.ts:96-103) и профиль zones_v1 полностью конфигурируется в редакторе (radar-editor.ts, editors/radar-section.ts), но результат нигде не отображается. AC S1-13 требует «2 px outline и ≤0.08 fill opacity» для известных занятых зон — реализации нет: класса .radar-zone, заливки или контура не существует. Пользователь, настроивший zones_v1-радар, не увидит на плане вообще ничего — профиль нефункционален целиком, не частично.

H6 — смена HA-привязки маркера может молча удалить уже сохранённую, откалиброванную конфигурацию радара. src/houseplan-editor-runtime.ts (два места смены привязки, ориентировочно ~13019 и ~13077): radarRemove: d.radarRemove || !!d.radar срабатывает при любом истинном d.radar — включая случай, когда это существующий сохранённый marker.radar (загружен через radarDraft с reason:'saved'), а не только несохранённое ручное объявление Q5. При следующем Save (_saveMarker, radarField = { radar: null }) ранее откалиброванная конфигурация стирается без предупреждения и без возможности восстановления — это отличается от явного требования спецификации для аналогичного случая смены пространства (§6: «preserves original block inert as needing setup, never projects it onto another floor»). Смена привязки радара сегодня — не «неактуально до ремонта», а безвозвратная потеря настройки.

Medium (в скоупе задачи — чинится в этой же ветке)

M1 — в «Stage 1» реализована валидация Stage 2/3. custom_components/houseplan/radar_validation.py: _validate_stage2 (232-289) полностью валидирует radar.zones.local (полигоны, кворум состояния), radar.zones.hardware (esphome_ld2450_numbers_v1, упорядоченные слоты number-сущностей) и radar.reflectors; _validate_settings (376+) валидирует settings.radar.fusion_groups/room_outputs (уникальность групп, состав 2..8 маркеров, эксклюзивность членства). Типы объявлены в src/types.ts: 154-155,317-318; тесты tests_backend/test_radar_validation.py прямо названы test_stage2_*/test_valid_fusion_and_room_output_settings. Согласно docs/specs/485-radar-presence-stage1.md (шапка документа: «Stages 2 and 3 remain separate, unimplemented scopes») и §1 («Excluded: … zone writes/ drawing, reflectors, multi-room fusion, heat maps and derived HA entities (specified in stages 2–3)») этого быть не должно. §6 разрешает лишь «Known-only field validation does not strip Stage-2/3 extensions or unknown siblings» — это требование не терять неизвестные поля транзитом, а не писать для них полноценные бизнес-правила заранее. Ни одного рантайм-эффекта (запись устройства, вычисление тепловой карты, производные HA-сущности) в дифф не входит — это чисто валидационный код и типы, но объём (три функции, отдельные тесты, поля в types.ts) — не «на всякий случай одна строка», а предвосхищение ещё не начатых этапов. §10 спецификации прямо предупреждает: «Verify the implementation/release against that boundary; do not silently broaden it.» Нужно либо вырезать _validate_stage2/расширения _validate_settings/связанные тесты и типы из этого PR (оставив реальный инертный passthrough), либо получить явное подтверждение владельца, что досрочная валидация Stage 2/3 — осознанное решение для этого раунда.

M2 — фолбэк распознавания радара — текстовый поиск по manufacturer/model. src/radar-editor.ts:107-109:

if (/\b(?:mmwave|mm-wave|radar|presence radar|fp2|fp1e|ld24(?:10|12|50))\b/.test(haystack))
  return { eligible: true, profile: 'presence_v1', reason: 'radar_metadata' };

где haystack — конкатенация device.model/registryDevice.model/ registryDevice.manufacturer. Спецификация требует: «Match evidence is structural adapter/registry metadata, not a friendly-name search» (§2). Устройство, чьи manufacturer/model случайно содержат одно из этих слов, получает полный раздел «Присутствие на плане» без проверки единой сущности — это именно текстовый поиск по имени, просто по полю model/manufacturer, а не по entity friendly name. Маловероятно на практике, но противоречит букве требования и не имеет теста на ложное срабатывание.

M3 — «Change installation» отсутствует как функция, не только как строка. Grep по всему диффу не находит UI-контрола change_installation; свежий installation_id/UUID не генерируется нигде, кроме самого первого черновика (radarDraft, radar-editor.ts:177-179, дальше переиспользует original.mount.installation_id при каждом последующем сохранении). §6 прямо требует явного действия, создающего новый installation UUID даже при неизменных итоговых x/y («Moving a physical sensor away and back must use Change installation even when final x/y happen to be identical») — сегодня это в принципе недостижимо из интерфейса, а не просто не подписано. Отдельно i18n-ключ radar.discard_setup («Discard the unsaved radar setup?») тоже не существует — потому что RadarSetupController.cancel()/interrupt() (radar-setup.ts:121-126,329-333) вызывают reset() безусловно: закрытие «грязного» мастера после захвата контрольных точек ничего не спрашивает, хотя §5.2 требует «Closing a dirty wizard asks to discard.»

M4 — неоднозначный результат калибровки маскируется под «плохие референсы». Исключение решателя ambiguous_sources (radar-geometry.ts:88, срабатывает, когда обе гипотезы зеркала проходят с разницей RMS <10 см — именно случай «не выбирай молча» из AC S1-8) в radar-setup.ts:316 транслируется в radar.bad_references («Choose separated references in different directions from the sensor»). Ключа radar.ambiguous_sources нет ни в одном словаре (проверено grep). Пользователь с корректно расставленными, но геометрически неразличимыми по зеркалу точками получит указание переставить точки — хотя переставлять, возможно, нечего. Это ровно тот сценарий честности калибровки, который §5.2/AC S1-8 выделяют особо.

M5 — «Это радар присутствия» не в конце тела диалога, как требует §2. Спецификация: «In the marker editor's collapsed Additional actions group at the end of its body». Блок радара (включая <details class="radar- additional"> из src/editors/radar-section.ts:96-101) вставлен в src/houseplan-editor-runtime.ts:13116 сразу после выбора комнаты и до Tap action, Controls, Value badge, Model/Link/Description/PDF и футера с кнопками — то есть в верхней трети тела диалога, а не в его конце.

M6 — 2 из 8 обязательных кодов ошибок никогда не возвращаются backend'ом, и защитные гварды §5.2/AC S1-8 не покрыты тестами. grep -r "unsupported_capability\|source_unavailable" custom_components/ houseplan/*.py — ноль совпадений; все остальные 6 кодов (invalid_radar, not_ready, source_restricted, invalid_selection, conflict, rate_limited) реализованы и различимы. Отдельно: ни один тест (TS или Python) не проверяет отдельно guard'ы 50 см от точки монтажа, предел RMS ≤20 см/индивидуальной ошибки ≤30 см или собственно порог неоднозначности зеркала <10 см в solveRadarTwoPoint/radar_validation.py (test/radar-geometry.test.mjs доходит только до тривиального успеха и отклонения коллинеарных точек) — удалённый или ослабленный guard прошёл бы все текущие тесты незамеченным, при том что AC S1-8 прямо требует «remove each guard => invalid-fit acceptance fails» как критерий готовности.

Low (на усмотрение автора, не блокируют)

  • docs/USER-GUIDE.md/USER-GUIDE.ru.md цитируют переключатель как «Show presence on the plan» / «Показывать присутствие на плане», а реально выпущенный ключ — gs.radar_show_live = «Show live presence on the plan» / «Показывать текущее присутствие на плане». Спецификация разрешает переименование ключей в процессе ревью, но текст гайда тогда должен синхронизироваться с итоговой строкой — сейчас оба языка цитируют несуществующую в UI фразу.
  • radarHealthI18nKey/RADAR_HEALTH_KEYS (radar-model.ts:16-21) не имеет отображения для backend-статусов restricted/incomplete — оба схлопываются в общий radar.health_unknown, маскируя «нет доступа к источникам» под «состояние неизвестно».
  • В src/radar-editor.ts предиктор допустимости (recognizeRadar) живёт не в radar-model.ts, как предполагает архитектура спецификации («Proposed radar-model.ts owns the pure section predicate»), а в radar-editor.ts. Не бага, но отступление от предложенной, хоть и необязательной, структуры.
  • Расширенная диагностика (radar-section.ts:438-440) не показывает report_age, хотя RadarEditorDraft.inspection.sources[].reported_at типизирован и получен — противоречит §2 «Advanced displays exact source ids, values, units and report ages», но это единственное поле из перечисленных четырёх, которого не хватает.

Что проверено и корректно

  • S1-3 (единицы/сигналы отсутствия) — точные коэффициенты mm/cm/m/in/ft, unknown/unavailable/NaN/Infinity не трактуются как ноль, x=0 и отрицательный x легальны — подтверждено чтением и общими TS/Python фикстурами (test/fixtures/radar-source-boundaries.json, test_radar_geometry.py). Единственный пробел — не проверяется конфликт задекларированной единицы с unit_of_measurement самой сущности (не поднимаю до Medium: сохранение по крайней мере отклоняет неизвестную/ отсутствующую единицу, требуемого поведения по конфликту с фактическим HA атрибутом просто нет ни в одну, ни в другую сторону).
  • S1-2/специфика LD2450 (кроме same-device, см. H3) — пара X=0,Y=0 трактуется как явное отсутствие только для esphome_ld2450_v1, единичный нулевой ноль по одной оси или частичная пара — нет; cartesian_v1 не получает этого спецповедения. Подтверждено чтением и тестом test_ld2450_pair_zero_is_absent_but_single_zero_axis_is_valid.
  • pair_quality bounded_latest — окно 3с/скос 1.5с и 100мс коалесинг реализованы точно по спецификации, слова «coherent»/атомарный фрейм нигде не заявлены необоснованно.
  • Границы подписок/публикаций (32 радара/пространство, ≤256 целей, 4 Гц/ радар, ≤10 inspect/мин, ≤4 subscribe/пользователь, ≤1 setup-подписка/ пользователь/маркер и ≤8 глобально) — все явно закодированы и проверяемы.
  • S1-7 (физическая точка монтажа независима от иконки) — формула проекции в radar-geometry.ts:21-37 совпадает с §5.1 буквально, включая знак зеркала и масштаб 240*cell_cm; общие фикстуры покрывают ±x, y=0, все 4 heading, зеркало, имперские единицы.
  • S1-12 (диагностический след) — TRAIL_MS=8000, ограничение 32 сэмпла/ слот, разрыв следа при скачке >1 м, сессионное хранение, очистка на _closeMarkerDialog/скрытии страницы — всё подтверждено чтением.
  • S1-17 (ленивая загрузка) — radar-editor.ts/editors/radar-section.ts/ radar-setup.ts попадают только в уже существующую ленивую границу houseplan-editor-runtime.ts; radar-live.ts/radar-render.ts/ radar-model.ts статически входят в основной View-бандл — осознанно, по комментарию в scripts/bundle-budget.mjs (контроллер живого присутствия должен быть в исходном графе View, крупный setup-UI остаётся ленивым). Нет простаивающих таймеров/подписок при отключённом радаре — sync() гейтится по capability/режиму/настройкам/наличию сконфигурированных радаров/видимости страницы.
  • S1-15/совместимость (import_export.py) — ремап/сохранение marker.radar.room_id/allowed_room_ids при merge/duplicate/removal, корректная зачистка блока при виртуализации маркера, «неизменённый будущий блок переживает несвязанную запись, а изменённый — отклоняется» — подтверждено чтением и тестом test_untouched_future_radar_round_trips_but_changed_one_fails.
  • Генерация/ревизия (§6) — source_generation хэширует installation_id, mount x/y, профиль, источники, пространство; calibration_revision — отдельно heading/calibration; клиентский UUID нигде не используется как токен авторизации.
  • Трейлеры и changelog — коммит 578df257 (User-Visible: yes) содержит правки обоих docs/CHANGELOG.md/docs/CHANGELOG.ru.md в себе же; все последующие коммиты этого диапазона — User-Visible: no, что соответствует тому, что видимое поведение не менялось после первого коммита. Issue: #485 проставлен во всех коммитах диапазона.
  • Golden baseline general-color-popover-desktop-en — принят отдельным коммитом 4aacf8a4 с обоснованием (рост общих настроек из-за нового переключателя) и ссылкой Baseline-Reviewed на прогон CI — соответствует процессу принятия эталонов, не самопроизвольная правка.
  • config-schema.json/config-field-registry.mjs — новые записи settings.radar/markers[].radar с allowExtra, описанием миграции и совместимости — соответствуют требованию §6 «Extend … field registry».

Итог

6 High и 6 Medium (все — в скоупе этой ветки). Наличие High блокирует независимо от того, что часть архитектуры (проекция, калибровочный решатель, границы подписок, ленивая загрузка, совместимость конфигурации) сделана добротно и проходит собственные тесты. Возврат автору целиком одним раундом: и backend (здоровье/teardown/same-device), и frontend (контур комнаты/зоны/потеря конфигурации) требуют реальных исправлений кода, а не только текста; рекомендую отдельно решить вопрос M1 с владельцем ТЗ, прежде чем чинить его техническим вырезанием кода, поскольку это может быть намеренным дизайн- решением, не описанным явно в комментарии реализатора.


Материал раунда

  • Ветка: issue/485-radar-presence, коммит f9acaa35de4f — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: e85d8ed7d8c980c191829379ead20e227dd01a37
    git log --all --format='%H %T' | grep e85d8ed7d8c9
    
  • ТЗ docs/specs/485-radar-presence-stage1.md, блоб f6e7e122303a4c00192be58cdc387bbc4859c1e6
    git log --all --find-object=f6e7e122303a4c00192be58cdc387bbc4859c1e6 -- docs/specs/485-radar-presence-stage1.md
    
  • ТЗ docs/specs/485-radar-presence-stage2.md, блоб b403e73fc26ba0ffb97be4ffa73be067d179880f
    git log --all --find-object=b403e73fc26ba0ffb97be4ffa73be067d179880f -- docs/specs/485-radar-presence-stage2.md
    
  • ТЗ docs/specs/485-radar-presence-stage3.md, блоб c65e28389d762216c47f00249bdb751f3c9d5082
    git log --all --find-object=c65e28389d762216c47f00249bdb751f3c9d5082 -- docs/specs/485-radar-presence-stage3.md
    
  • ТЗ docs/specs/485-radar-presence.md, блоб 3297ed0140265ad86f2072a40cb9bf8097c0674d
    git log --all --find-object=3297ed0140265ad86f2072a40cb9bf8097c0674d -- docs/specs/485-radar-presence.md