Files
houseplan-card/docs/superpowers/specs/2026-08-09-vacuum-integration-coverage-design.md
T
Matysh 112c260314
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 3m2s
Validate / golden (push) Failing after 51s
Validate / backend (push) Failing after 6m50s
Validate / smoke (push) Failing after 13m44s
Validate / performance (push) Failing after 24m13s
Release v1.61.0-beta.1
2026-08-09 21:51:33 +03:00

286 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 — один `<path>` с несколькими `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.