# HP-VAC-02 — Покрытие интеграций роботов-пылесосов (ревизия 7) Статус: **ревизия 7 — утверждено владельцем; Stage 1 реализован, целевой gate v1.61.0-beta.1 пройден, ожидается публикация** Дата: 2026-08-09 (р.1–р.7) Каноническая задача: [GitHub Issue #58](https://github.com/Matysh/houseplan-card/issues/58). Область (уточнена по B12): **`src/vacuum.ts`** (source-классификация, телеметрия, нормализация пути), **`src/houseplan-card.ts`** (source resolver, диагностика, multi-subpath рендер), **`custom_components/houseplan/trails.py`** (health источника; во 2-м этапе — строковый парсер), **i18n ru/en, фикстуры Node/Python/смоков, VACUUM.md/USER-GUIDE/README/CHANGELOG**. Модель сохранения плана не меняется. ## 0. Ответ на ревью R1 | # | Вердикт | Решение | |---|---|---| | B1 nonce | **Принято, факт верифицирован** (ValetudoMap.js:41 `crypto.randomUUID()`) | `vacuum_json_id` полностью исключён; §4.3 заменён защитным контрактом стабильности map-id | | B2 XCME вне устройства | **Принято** | Явный выбор источника из отобранных кандидатов (§4.5); отступление от принципа «no entity pickers» вынесено владельцу (§10.1) | | B3 Roomba недостаточен | **Принято, вариант 1 поэтапно** | Roomba перенесён в Этап 2 отдельной бетой с ПОЛНЫМ скоупом: оба парсера (TS+Python), калибровка с масштабом, полевой протокол из 2 уборок + рестарт (§7) | | B4 silent rebind | **Принято** | `VacSourceResolution` со статусом и sticky-семантикой (§4.4) | | B5 capability-обещание | **Принято** | Матрица возможностей вместо «всё для всех» (§2) | | B6 null в Pt[] | **Принято** | Нормативный `VacPath = Pt[][]` + правила (§4.1) | | B7 центроид L-комнаты | **Принято частично, со спором** | Разделение якоря и bbox принято. Point-on-surface ОТКЛОНЁН: якорь автокалибровки не обязан лежать внутри комнаты — он должен быть ОДИНАКОВО определён с обеих сторон матча. Наши plan-комнаты дают area-центроид; тот же area-центроид для outline даёт согласованные пары даже для вогнутых форм (обе точки «выпадают» из выреза согласованно, least squares это устраивает, резидуал-порог ловит вырожденные случаи). Acceptance «лежит внутри» удалён, заменён точными координатами по формуле (§4.2, §8.2) | | B8 недетерминированный приоритет | **Принято** | Классификатор с явным скорингом, `parseVacSourceCandidate(entityId, state, registryEntry)` (§4.4) | | B9 хрупкий regex | **Принято** | Парсер-грамматика вместо regex, общая для TS/Python, shared-фикстура (Этап 2: §6/§7.6) | | B10 диагностика-капабилити | **Принято** | Блок диагностики по возможностям + `path` в YAML-подсказке + фокусируемая ссылка (§5.1) | | B11 lifecycle warning | **Принято** | Машина состояний с dedupe-ключом; честное «обнаружение при следующем refresh/restart» без registry-подписки в этой итерации (§5.2) | | B12 неполный scope | **Принято** | Шапка области исправлена | ## 0-bis. Ответ на ревью R2 | # | Вердикт | Решение | |---|---|---| | R2-B1 plan-якорь | **Принято, факт признан** — plan-сторона использует `poleOfInaccessibility` (houseplan-card.ts:12148), а не area-центроид; моё утверждение р.2 о «согласованности обеих сторон» было неверным. Вариант 1: новый pure helper `areaCentroid(poly)` для ОБЕИХ сторон автокалибровки (plan-полигон и outline), `poleOfInaccessibility` остаётся для подписей комнат. Смена plan-якорей при НОВОЙ автокалибровке признаётся явно; сохранённые матрицы не мигрируют. High-residual: выбрано поведение «подтверждение» — матрица НЕ сохраняется молча; диалог «Совпадение неточное (~N см): Применить / Подогнать вручную» (текущее сохрани-потом-предупреди признано дефектом). | | R2-B2 арбитраж пути | **Принято** — нормативный порядок для текущего run: `tele.path` (полная геометрия) → server current → local runtime; сервер остаётся источником истории и fallback после reload. `always` показывает integration path и в покое; `cleaning` — только пока moving. Смок: одновременные srvCur + двухсегментный tele.path → разрывы видимы. | | R2-B3 категории кандидатов | **Принято** — compatible / partial / known_xcme_incomplete / sticky-saved (§4.5); XCME-подсказка только для known_xcme_incomplete (platform точно равна xiaomi_cloud_map_extractor) или явно выбранной пользователем камеры. Обычная камера без vacuum-признаков никогда не получает vacuum-подсказку. | | R2-B4 unverified | **Принято** — resolver обязан использовать `resolveHaBindingStatus(hass, 'entity:'+source)` / HaRegistrySnapshot; enum + `unverified` (limited registry ≠ доказательство удаления), sticky, то же limited-registry сообщение, что у устройств. | | R2-S1 explicit | **Принято** — флаг переименован в `pinned`: любой сохранённый source sticky независимо от происхождения (авто-закрепление _vacSaveMatrix или picker); поле origin НЕ добавляется, модель не меняется. | | R2-S2 бюджет сегментов | **Принято** — детерминированный контракт: порядок XCME old→new; при >64 сегментов сохраняются ПОСЛЕДНИЕ 64; каждому drawable-сегменту гарантированы endpoints; бюджет 4000 точек распределяется largest-remainder (tie-break old→new); bounded thinning строго внутри сегмента. | | R2-S3 tip | **Принято** — tip крепится к последнему **drawable** (≥2 точек) сегменту; singleton не рисует ни линии, ни tip-связи (смок: [drawable, singleton] не соединяет singleton с puck). | | R2-S4 деградация комнаты | **Принято; дополнено owner override F6** — валидный явный якорь сохраняет комнату при битом outline; отдельная полная bbox-четвёрка сохраняет ghost и даёт последний bbox-centre anchor. Комната пропускается лишь без явного, outline- и bbox-якоря. | | R2-S5 этап 2 | **Принято** — §6 «Этап 2» помечен как неисполняемое направление: реализация только по отдельному ТЗ после решений владельца; acceptance этапа 1 от него не зависит. | | R2-S6 переходы reason | **Принято** — один активный health-state на (marker, source): смена missing↔disabled обновляет reason БЕЗ второго warning; новый warning только после возврата в available. Таблица переходов в §5.2. | ## 0-ter. Ответ на ревью R3 | # | Вердикт | Решение | |---|---|---| | R3-B1 registry-less XCME | **Принято, оба факта верифицированы** (camera.py: generate_entity_id без unique_id; ha-binding-status.ts:442: authoritative без строки → orphaned не глядя на live state) | Нормативный порядок доказательств существования сущности (§4.4) правится в ОБЩЕМ `resolveHaBindingStatus` — кейс системный, не vacuum-специфичный. UX-канал для XCME без атрибутов: расширенная секция picker «Все камеры» с нейтральным предупреждением; XCME-подсказка — после явного выбора камеры без position-атрибутов. `known_xcme_incomplete` по registry-платформе остаётся ДОПОЛНИТЕЛЬНЫМ быстрым путём, когда строка реестра есть | | R3-B2 high-residual flow | **Принято** | Полный нормативный flow перенесён в §4.2-bis: физический порог 40 см через cell_cm/grid pitch, отображение через HA unit formatter, конфиг не меняется до подтверждения, четыре ветки протестированы | | R3-B3 drawable-арбитраж | **Принято** | Приоритет источников пути считается только по наличию drawable-сегмента (≥2 точек); pure helper `resolveCurrentVacPath` для рендера и тестов; кейсы [[]], [[p]], non-finite-only, [singleton,drawable]+srvCur | | R3-S1 tip-противоречие | **Принято** | Старая фраза «непустому сегменту» удалена, осталось единственное правило «последний drawable» | | R3-S2 XCME-подсказка | **Принято** | «или неопределима» удалено; подсказка только known_xcme_incomplete ЛИБО явно выбранной камере | | R3-S3 sticky-статусы | **Принято** | Любой не-`ok` статус сохранённого источника sticky, включая unverified/unsupported | | R3-S4 acceptance Dreame/demo | **Принято** | Переформулировано: сохранённые матрицы/следы байт-неизменны; НОВАЯ автокалибровка может дать иную матрицу по новому алгоритму, но обязана пройти фикстуры в утверждённом резидуале | | R3-S5 tie-break бюджета | **Принято** | Largest-remainder, tie-break по порядку old→new | | §10.1 picker | **РЕШЕНИЕ ВЛАДЕЛЬЦА ПОЛУЧЕНО 2026-08-09: подтверждён, с формулировкой Codex** — «никаких ручных entity_id/YAML-полей; разрешён объяснимый picker автоматически найденных источников и отдельная расширенная секция camera для registry-less XCME» | ## 0-quater. Ответ на ревью R4 | # | Вердикт | Решение | |---|---|---| | R4-B1 §4.2-bis отсутствовал | **Принято, признан брак редактирования** — replace-патч ревизии 4 не совпал с якорем и молча не применился (тот же класс дефекта, что я ловлю в чужих тестах: правка без ассерта на успех). §4.2-bis фактически добавлен, §7.4 и acceptance дополнены | | R4-B2 drawable-арбитраж отсутствовал | **Принято, та же причина** — §4.1 фактически заменён: helper `resolveCurrentVacPath` с полем `source`, drawable-условие, пять пар одновременных источников в §7.3 | | R4-S1 ссылка §4.4-bis | **Принято** — исправлена на §4.4 | | R4-S2 таблица статусов | **Принято** — полное отображение семи статусов в §4.4, включая sticky `unsupported` для явно выбранной registry-less камеры | | R4-S3 скоринг vs sticky | **Принято** — шаг 1 переформулирован под общее правило | ## 0-quinquies. Ответ на ревью R5 | # | Вердикт | Решение | |---|---|---| | R5-B1 ключ health-state | **Принято, внутреннее противоречие признано** — dedupe-ключ с reason из р.1 конфликтовал с единой моделью R2-S6. Нормативно: ключ `(marker_id, source_entity_id)`, reason — поле записи; §7.5 расширен полной цепочкой со сменой причины | | R5-B2 singleton vs cap | **Принято** — pipeline из пяти шагов, cap 64 применяется к DRAWABLE-сегментам после фильтрации; VacPath не содержит недорисовываемого; три новых теста в §7.3 | | R5-S1 markdown таблицы | **Принято** — граница таблицы исправлена | | R5-S2 дата ревизий | **Принято** — р.1–р.6 | | R5-S3 ссылки B4/B8/B9 | **Принято** — §4.4 и Этап 2 | | R5-S4 условие подсказки | **Принято** — записано булевой формулой | ## 0-sexies. Ответ на ревью R6 | # | Вердикт | Решение | |---|---|---| | R6-B1 recovery не определён | **Принято** — health-машина связана с полным enum §4.4: failure = missing|disabled; доказанное recovery = ok|unavailable|unsupported; unverified нейтрален (держит активную запись, не создаёт новую); четыре новых теста в §7.5 | | R6-B2 веса largest-remainder | **Принято** — формула зафиксирована: веса = внутренние точки (n_i − 2), резерв 2k endpoints, floor + дробный остаток, tie old→new; юнит с неодинаковыми n_i на точные target-counts | | R6-S1 фраза §5.1 | **Принято** — заменена буквальной формулой §4.5 | ## 1. Продуктовое решение Довести три Tier-A семейства до честно задокументированного состояния «работает» по их фактическим возможностям (матрица §2), добавить надёжный выбор источника для интеграций вне device registry (XCME), сделать диагностику пообъектной, и отдельным этапом — Roomba с полноценной Tier-B калибровкой. Никаких обещаний возможностей, которых нет у upstream. ## 2. Матрица возможностей (нормативная; заменяет прежние цели §2.1) | Семейство | Позиция | Комнаты/автокалибровка | Integration path | Стабильный map-id | Обнаружение источника | |---|---|---|---|---|---| | Dreame/Mova (Tasshack) | да | да | нет (нет в атрибутах) | `map_name`/`map_index` + vacuum `selected_map` | same-device auto (как сейчас) | | Xiaomi Cloud Map Extractor | да, при включённом `attributes:` | да | **да** (`path.path`) | `map_name` | **явный выбор** (§4.5) — камера вне device registry | | MQTT Vacuum Camera (Valetudo) | да | да (outline) | нет в state-атрибутах | **не подтверждён** — `default`/`selected_map`; nonce не используется | same-device auto | | Roomba core (Этап 2) | часть моделей (`cap.pose`) | нет | нет | нет | сама `vacuum.*` (низший приоритет) | Статусы «verified live» / «implemented from upstream sources» ведутся отдельно от матрицы (issue «Integration coverage matrix», §7.3) и не смешиваются с возможностями. ## 3. Не входит Как в р.1 (Ecovacs, прямой MQTT, Tuya/Neato/Shark/Eufy, Tier C — отдельный этап по VACUUM.md, Roborock-поллер — решение владельца, команды роботу) + **`vacuum_json_id` и любые нестабильные upstream-поля как ключи хранения**. ## 4. Нормативные контракты ### 4.1. Путь: `VacPath = Pt[][]` (закрывает B6) ```ts type VacPath = Pt[][]; // всегда массив subpath'ов // legacy плоский путь нормализуется в [points]; серверный/локальный след // передаётся рендереру как [trail] БЕЗ изменения storage-схемы. ``` **Pipeline нормализации (R5-B2), строго в этом порядке:** 1. нормализовать точки, выбросить non-finite; 2. удалить из render-path сегменты с `length < 2` (singleton не несёт семантики: не линия, не tip, не позиция — позиция приходит только из `pos`; raw-данные могут оставаться доступными диагностике); 3. оставить последние **64 drawable-сегмента** (cap применяется ПОСЛЕ фильтрации — singleton'ы не могут вытеснить drawable); 4. распределить бюджет 4000 точек между оставшимися drawable-сегментами по формуле (R6-B2): `k` сегментов длины `n_i ≥ 2`; резерв endpoints `base = 2k`; если `Σn_i ≤ 4000` — все точки сохраняются; иначе `remaining = 4000 − base`, веса — ВНУТРЕННИЕ точки `d_i = n_i − 2`, идеальная квота `q_i = remaining · d_i / Σd_i`, выдача `floor(q_i)` + остаток по убыванию дробной части (tie-break old→new); итоговый target сегмента `2 + allocated_i`; thinning до target строго внутри сегмента с сохранением первой и последней точки; 5. по результату выполнить `resolveCurrentVacPath` — возвращаемый `VacPath` не содержит недорисовываемых сегментов. Прочие правила: thinning и transform выполняются per-subpath, никогда поперёк разрыва; SVG — один `` с несколькими `M` (без соединительных отрезков — смок-ассерт); в `trail_mode=cleaning` integration path скрывается вместе со следом по завершении уборки, в `always` — остаётся. Формы входа: существующие (`[{x,y}]`, `[[x,y]]`, `path.points`) + XCME `path.path: [[{x,y},…],…]`. **Арбитраж источников текущего пути (R2-B2 + R3-B3/R4-B2), нормативно.** Единственная реализация выбора — pure helper: ```ts resolveCurrentVacPath(tele, srv, rt): { path: VacPath; source: 'integration' | 'server' | 'local' | 'none' } ``` Порядок (участвуют только источники, имеющие ≥1 drawable-сегмента, т.е. ≥2 конечных точек ПОСЛЕ нормализации): (1) нормализованный `tele.path` с drawable-сегментом → `integration`; (2) drawable server current как `[trail]` → `server`; (3) drawable local runtime как `[trail]` → `local`; (4) иначе `none`. Renderer и диагностика читают `source` из результата и не выводят происхождение собственными условиями. Сервер продолжает записывать всегда (история, previous run, fallback после reload/исчезновения tele.path). Режимы: `always` — integration path виден и в покое; `cleaning` — только пока робот moving; previous run — только из серверного стора (как сейчас). **Бюджет (R2-S2):** сегменты в порядке поступления old→new; при превышении 64 отбрасываются старейшие; каждому drawable-сегменту гарантированы обе конечные точки; бюджет 4000 точек делится по largest-remainder с tie-break в порядке old→new; thinning строго внутри сегмента. **Tip (R2-S3):** последний drawable (≥2 точек) сегмент; singleton не рисуется и не связывается с puck. ### 4.2. Комнаты: якорь ≠ bbox (закрывает B7) Два независимых понятия: - **Якорь автокалибровки** (`cx/cy`): приоритет `cx/cy → center.{x,y} → явные x/y → areaCentroid(outline) → центр полного bbox x0/y0/x1/y1`. Последний bbox-tier добавлен прямым решением владельца после code-review F6 как дешёвая compatibility-страховка для bbox-only диалектов, несмотря на прежний запрет rev.7; он никогда не побеждает более точную геометрию. Согласованность основных tier обеспечивается НОВЫМ pure helper `areaCentroid(poly)` (shoelace), который используется ОБЕИМИ сторонами матча: plan-полигоном в `_vacAutoCalibrate` (вместо `poleOfInaccessibility`, который остаётся для подписей комнат) и outline-фолбэком робота. Изменение plan-якорей затрагивает только НОВЫЕ автокалибровки; сохранённые матрицы не мигрируют и остаются валидными. Для актуального Valetudo-формата явные `x/y` публикуются парсером и побеждают — outline-центроид это фолбэк. - **Bbox для fit-ghost** (`x0..y1`): min/max по вершинам валидного outline, вычисляется даже когда якорь взят из явных `x/y`. Вырожденные входы (детерминированный skip, юниты обязательны): outline < 3 точек; замыкающая дублирующая вершина (допустима, игнорируется); нулевая площадь → фолбэк на среднее вершин как якорь, bbox честный; non-finite вершина outline → outline-bbox/ghost пропадает, но явный якорь либо отдельная полная bbox-четвёрка сохраняют комнату (R2-S4 + F6); комната пропускается лишь без явного/outline/bbox якоря; самопересечение НЕ детектируется (shoelace от него не падает — фиксируем поведением «формула как есть», тест с бабочкой на точные координаты). ### 4.2-bis. High-residual flow автокалибровки (R2-B1 + R3-B2/R4-B1), нормативно 1. `residual` — максимальная евклидова ошибка по matched-якорям в plan-единицах (семантика существующего `affineResidual`), не среднее. 2. Физическая ошибка: `residual / resolvedGridPitch * resolvedCellCm` (см). 3. High residual — строго `> 40` см (граница выбрана как ~полкорпуса робота: меньшее расхождение неотличимо от шума телеметрии). Текущий канвасный порог `NORM_W * 0.05` удаляется — процент холста означает разную физическую ошибку при разных cell_cm. 4. Отображение величины — общий форматтер единиц HA (metric/imperial), никакого хардкода «см» в строках. 5. До подтверждения предложенная матрица НЕ записывается в config и не меняет сохранённую калибровку (текущее «сохранить → предупредить» — дефект, устраняется). 6. Low residual: матрица сохраняется сразу, существующий success-тост. 7. High → «Применить»: сохраняется именно предложенная матрица, success. 8. High → «Подогнать вручную»: открывается fit-панель, инициализированная предложенной матрицей; сохранение — только по Apply внутри панели. 9. Cancel/закрытие диалога: прежняя калибровка нетронута, ложный success не показывается. ### 4.3. Map-id: защитный контракт (заменяет прежний §4.3; закрывает B1) Цепочка `map_name ?? current_map ?? map_index ?? selected_map (source) ?? selected_map (vacuum) ?? 'default'` **не меняется**. `vacuum_json_id` не используется нигде. Новый обязательный guard-тест (Node + Python от одной JSON-фикстуры): два последовательных обновления Valetudo-атрибутов с разными nonce → тот же map-id, тот же продолжающийся run, calibration находится. Существующие кейсы `0`/`"0"`/`""`/`null` сохраняются. В матрице §2 и USER-GUIDE честно: у Valetudo multi-floor стабильного map-id нет — калибровка живёт под `default`/`selected_map`. ### 4.4. Классификация источников (закрывает B4, B8) ```ts type VacSourceResolution = { entityId: string | null; status: 'ok' | 'missing' | 'disabled' | 'unavailable' | 'unverified' | 'unsupported' | 'none'; pinned: boolean; // сохранён в marker.vacuum.source (любое происхождение) candidates: VacSourceCandidate[]; // для диалога }; ``` Единственный классификатор `parseVacSourceCandidate(entityId, state, registryEntry?)`; статусы определяются через общий `resolveHaBindingStatus`/`HaRegistrySnapshot` (R2-B4), в который вносится системная поправка (R3-B1) — **нормативный порядок доказательств для entity-привязки (§4.4)**: 1. registry-строка с `disabled_by` → `disabled` (приоритет); 2. живой `hass.states[entityId]` — положительное доказательство существования ДАЖЕ при authoritative snapshot без строки (легальный кейс: YAML-платформы без unique_id — XCME); state `unavailable` → source-статус `unavailable`, не `missing`; 3. registry-строка без disable ИЛИ живой state → сущность существует; 4. `missing` — только когда нет НИ строки, НИ живого state при authoritative; при limited — `unverified`. Поправка вносится в общий резолвер (кейс системный, не vacuum-локальный) с регресс-прогоном всех его существующих юнитов и смоков disabled-фичи. Тесты: authoritative без строки + живой XCME state → не missing; тот же с unavailable → unavailable; нет ни строки, ни state → missing; limited без обоих → unverified; disabled-строка → disabled. **Sticky (R3-S3): ЛЮБОЙ не-`ok` статус сохранённого источника sticky и не запускает автоподмену** (включая unverified/unsupported). Полное отображение статусов (R4-S2): | Условие | `status` | |---|---| | source не выбран и не найден | `none` | | привязка существует, state и позиция валидны | `ok` | | сущность существует, но position отсутствует/невалидна | `unsupported` (явно выбранная registry-less камера без атрибутов остаётся sticky `unsupported` и получает XCME-подсказку) | | state `unavailable` | `unavailable` | | registry `disabled_by` | `disabled` | | нет ни строки, ни state при authoritative | `missing` | | нет ни строки, ни state при limited | `unverified` | Скоринг, независимый от порядка массивов: 1. сохранённый `marker.vacuum.source` — **sticky**: любой статус (включая unverified/unsupported) отображается как есть и НИКОГДА не подменяется автоматически; 2. camera с объектной позицией (`vacuum_position`/`robot_position`); 3. иная сущность с объектной позицией; 4. (Этап 2) `vacuum.*` с валидной строкой `position`; 5. диагностический кандидат: camera без position-атрибутов (для подсказки, не источник). Один resolver обслуживает рендер, диалог, калибровку и диагностику; бэкенд получает только сохранённое значение. Юниты: перестановка `d.entities` не меняет выбор; camera бьёт vacuum-строку; `position`-строка на `sensor.*`/`device_tracker.*` — не источник. ### 4.5. Выбор источника для XCME (закрывает B2) Кандидаты диалога (R2-B3), четыре категории: - `compatible` — объектная позиция есть (same-device + глобальный скан `camera.*`); - `partial` — позиции нет, но есть хотя бы один vacuum-признак (`rooms`/`path`/`map_name`); - `known_xcme_incomplete` — платформа по registry ТОЧНО `xiaomi_cloud_map_extractor`, независимо от атрибутов; - сохранённый sticky-source — всегда присутствует в результате, каким бы ни был его state. Глобальный скан — только для списка кандидатов, НЕ для автопривязки. Поскольку XCME-камера обычно ОТСУТСТВУЕТ в реестре (нет unique_id), канал её выбора (R3-B1, утверждён владельцем): в picker добавляется свёрнутая расширенная секция **«Все камеры»** — все `camera.*` из hass.states с нейтральным предупреждением «камера не отдаёт данных робота; выберите, только если это карта вашего пылесоса». **Условие XCME-YAML-подсказки, буквально: `known_xcme_incomplete` ИЛИ (камера явно выбрана пользователем И position-атрибуты отсутствуют).** Невыбранная камера из секции «Все камеры» подсказку не получает. Обычная камера без vacuum-признаков автоматически vacuum-подсказку не получает никогда; формулировка «или неопределима» исключена (R3-S2). UI: строка «Источник: {entity | не выбран}» + кнопка **«Выбрать источник»** со списком кандидатов (подпись: friendly name + платформа при наличии + какие данные найдены). Семантика: same-device auto-discovery остаётся default'ом для Dreame/Valetudo; выбор из списка записывает `marker.vacuum.source` (модель данных уже это умеет); удаление/переименование выбранного → статус `missing`, баннер с кнопкой «Выбрать другой источник» (не «перепоиск» — автоподмены нет); восстановление сущности → статус `ok` без действий пользователя. Global scan выполняется лениво при открытии секции (не на hass-тике). ## 5. Диагностика ### 5.1. Блок «Живая позиция» — по возможностям (закрывает B10, V3) Вместо одной итоговой фразы — строки-капабилити: источник (+платформа, если определима); позиция да/нет; комнаты: N найдено, пригодны ли для автокалибровки (≥3 матча имён); путь да/нет; map-id: значение или `default`; действия — «Настроить автоматически» / «Подогнать вручную» / «Выбрать источник» / «Документация» (настоящая кнопка-anchor с keyboard focus, не текст в строке). Спец-подсказка XCME — строго по условию §4.5: `known_xcme_incomplete` ИЛИ (камера явно выбрана пользователем И position-атрибуты отсутствуют); сам факт глобального обнаружения подсказку НЕ включает: ```yaml attributes: - vacuum_position - rooms - path - map_name ``` ### 5.2. Health источника в trails.py (закрывает B11, V5) Машина состояний (R5-B1, единая модель): ключ активного состояния — **только `(marker_id, source_entity_id)`**; `reason ∈ {missing, disabled}` — изменяемое ПОЛЕ записи, не часть ключа: ``` active_health: Map[(marker_id, source_entity_id)] -> { reason } ``` **Связь со статусами §4.4 (R6-B1), нормативно:** - failure-статусы (создают/держат запись): только `missing | disabled`; - доказанное recovery (удаляет запись, `info`-лог): любой статус, доказывающий существование без disable — `ok | unavailable | unsupported`; - `unverified` — нейтрален: НЕ новая потеря и НЕ recovery; активная запись сохраняется до следующего доказанного статуса, при отсутствии записи ничего не создаётся; - `none` (source не настроен) — запись очищается по lifecycle смены source/удаления маркера. Warning создаётся только при переходе из отсутствующего/recovered состояния в failure; `missing ↔ disabled` обновляет reason записи БЕЗ нового warning; после доказанного recovery следующая потеря создаёт снова один warning; старт HA с уже отсутствующим источником — один warning; старт с `unavailable`/`unsupported` — БЕЗ warning; повторные refresh — тишина; смена source и удаление маркера удаляют запись. Транзиентный `unavailable` — не warning И доказанное recovery активного failure (сущность снова существует). **Переходы reason (R2-S6):** один активный health-state на (marker, source); `missing → disabled` и обратно обновляют reason активного состояния БЕЗ нового warning; новый warning возможен только после полного recovery (`→ available →` потеря). Тест: `available→missing→disabled→missing→recovery→missing` = ровно 2 warning. Обнаружение — при config refresh/restart; entity-registry подписка не добавляется в этой итерации, что честно записано в VACUUM.md. Диалог показывает статус из §4.4, не из логов. ## 6. Этапность (закрывает B3) **Этап 1 (эта бета): Tier-A полировка** — §4.1 путь, §4.2 комнаты, §4.3 guard, §4.4–4.5 resolver+выбор источника, §5 диагностика/health, доки/CHANGELOG. **Этап 2 — НЕИСПОЛНЯЕМОЕ НАПРАВЛЕНИЕ (R2-S5):** реализация Roomba возможна только по отдельному ТЗ, которое будет написано после решений владельца (§10.2); acceptance и тесты этапа 1 от этого раздела не зависят. Зафиксированные требования будущего ТЗ: - общий парсер-грамматика строки `position` (не regex): проверка домена `vacuum.*` → снять ровно одну пару внешних скобок → split на ровно 3 части → `Number`/`float` → три конечных числа; `position: null` — не источник. Реализации TS **и** Python (trails.py `_sample`), обе от одной JSON-фикстуры; - калибровка, задающая масштаб и ориентацию без комнат: fit-панель получает режим «две отметки» (пользователь отмечает робота в двух разнесённых точках плана в разные моменты — решает translate+scale; rotation/mirror — существующими кнопками) ЛИБО ghost-квадрат с ручками масштаба; выбор конкретного UX — на ТЗ этапа 2; - полевой протокол (§7.3): две отдельные уборки + рестарт HA посреди уборки, проверка стабильности origin/масштаба между уборками; 30-секундный тест признан недостаточным; - деградация: модель без `cap.pose` → Tier D. ## 7. Тест-план (дополнен обязательными сценариями R1) **7.1 Source resolution:** перестановка entities; camera > vacuum-строка; sticky missing без подмены; явный выбор переживает reload; XCME-камера вне устройства видна в кандидатах; `position` на не-vacuum — не источник; disabled ≠ missing ≠ unavailable. **7.2 Map-id:** nonce-guard (два обновления → один run); кейсы `0`/`"0"`/`""`/`null`; Node и Python читают ОДНУ JSON-фикстуру (`test/fixtures/vacuum-attrs/*.json`, подключается и в pytest, и в node:test — расширение существующего cross-language паттерна DISPLAY_MODES). **7.3 Path:** юнит бюджета с неодинаковыми `n_i`: точные target-counts по формуле §4.1.4, сумма ≤4000, endpoints целы, tie-break по дробной части old→new; XCME два сегмента → `Pt[][]`; пустые/одноточечные/невалидные сегменты без линий и исключений; арбитраж при одновременных источниках: `[[]]`+srvCur → server, `[[p]]`+srvCur → server, non-finite-only+srvCur → server, `[singleton, drawable]`+srvCur → integration, drawable tele+srvCur → integration с сохранёнными разрывами; `[drawable, singleton×64]`+srvCur → integration (cap после фильтрации); `[singleton×65]`+srvCur → server; после cap остаются последние 64 DRAWABLE-сегмента, не последние 64 исходных; transform/thinning/tip per-subpath; лимиты сегментов/точек; `cleaning`/`always` после остановки; смок: нет соединительного отрезка между субпутями. **7.4 Rooms:** outline+x/y → якорь из x/y, bbox из outline; вогнутый L → якорь = точные координаты area-центроида (без containment-ассерта); bbox-only → нормализованный bbox и его центр как последний якорь; замыкающая вершина/нулевая площадь/non-finite/бабочка → детерминированный результат по §4.2; ≥3 матчей → конечная матрица, резидуал проверяется отдельно; high-residual flow: low (сохранение без диалога), high→Применить (сохранена предложенная), high→вручную→Apply, high→Cancel (конфиг не изменён). **7.5 Health:** полный сценарий переходов §5.2: `available→missing→disabled→missing→recovery→missing` = ровно 2 warning (смена причины внутри активного состояния — 0 новых); `available→missing→unavailable→missing` = 2 warning (unavailable = доказанное recovery); `available→disabled→unsupported→disabled` = 2 warning; `missing→unverified→missing` = 1 warning (unverified нейтрален); старт с `unavailable`/`unsupported` = 0 warning; `available→missing→refresh×3→recovery` = 1 warning; startup-missing = 1; удаление маркера/смена source чистит запись; disabled ≠ missing различимы в reason. **7.6 Этап 2 (Roomba):** shared-фикстура строки TS↔Python; рекордер пишет при закрытой карточке; калибровка определяет translate+scale+orientation; две уборки — совместимая СК; рестарт не рвёт run; без pose → Tier D. ## 8. Критерии приёмки (Этап 1) 1. Матрица §2 реализована буквально: каждая ячейка «да» покрыта юнит-фикстурой реального формата, каждая «нет» НЕ обещана в UI/доках. 2. XCME: путь рисуется с разрывами; камера, не привязанная к устройству, выбирается из кандидатов и переживает reload. 3. High-residual матрица НИКОГДА не попадает в config до явного «Применить» (тест ветки Cancel). 3-bis. Valetudo: автокалибровка проходит на outline-комнатах (якоря = area-центроиды, точные координаты в тесте); nonce-guard зелёный — обновления карты не рвут run и не теряют калибровку. 4. Сохранённый источник sticky: missing показывает баннер и не подменяется; recovery без действий пользователя. 5. Диагностика показывает пообъектные capability-строки; XCME-подсказка содержит все четыре атрибута; ссылка на доку фокусируема. 6. Health-warnings по §5.2 (тест 7.5). 7. Регресс: сохранённые матрицы и следы байт-неизменны (не мигрируют); НОВАЯ/повторная автокалибровка Dreame/демо может дать иную матрицу по areaCentroid-алгоритму, но обязана проходить существующие фикстуры в пределах утверждённого резидуала (R3-S4). 8. CHANGELOG en/ru + USER-GUIDE таблица §2 + VACUUM.md обновлён (включая честную запись про Valetudo map-id и отсутствие registry-подписки). ## 9. Верификационная кампания Как в р.1, с поправкой B3/R1-5: для XCME/Valetudo достаточно скриншота диагностики + короткой уборки; для Roomba (этап 2) — протокол §6: две уборки + рестарт, скриншоты fit до/после. Статусы полевой проверки — в issue, отдельно от матрицы возможностей. ## 10. Решения владельца 1. **Выбор источника (B2/R3-B1): РЕШЕНО владельцем 2026-08-09** — picker подтверждён с формулировкой: «никаких ручных entity_id/YAML-полей; разрешён объяснимый picker автоматически найденных источников и отдельная расширенная секция camera для registry-less XCME». Принцип VACUUM.md уточняется этой формулировкой. 2. **Roomba, этап 2:** подтвердить перенос и выбрать UX калибровки без комнат (две отметки vs ghost-квадрат) — уйдёт в ТЗ этапа 2. **[решение]** 3. **Roborock-поллер** — без изменений, отложен до полевых отчётов. **[решение из р.1 остаётся]** ## 11. Изменения бэклога Issue #9 (vacuum_json_id) закрывается как wontfix со ссылкой на B1; #10 (Roomba) переоформляется под этап 2 с полным скоупом; #6 дополняется контрактом `Pt[][]`; #7 — разделением якорь/bbox.