48 KiB
HP-VAC-02 — Покрытие интеграций роботов-пылесосов (ревизия 7)
Статус: ревизия 7 — утверждено владельцем; Stage 1 реализован, целевой gate
v1.61.0-beta.1 пройден, ожидается публикация
Дата: 2026-08-09 (р.1–р.7)
Каноническая задача: GitHub Issue #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 |
| 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)
type VacPath = Pt[][]; // всегда массив subpath'ов
// legacy плоский путь нормализуется в [points]; серверный/локальный след
// передаётся рендереру как [trail] БЕЗ изменения storage-схемы.
Pipeline нормализации (R5-B2), строго в этом порядке:
- нормализовать точки, выбросить non-finite;
- удалить из render-path сегменты с
length < 2(singleton не несёт семантики: не линия, не tip, не позиция — позиция приходит только изpos; raw-данные могут оставаться доступными диагностике); - оставить последние 64 drawable-сегмента (cap применяется ПОСЛЕ фильтрации — singleton'ы не могут вытеснить drawable);
- распределить бюджет 4000 точек между оставшимися drawable-сегментами по формуле (R6-B2):
kсегментов длиныn_i ≥ 2; резерв endpointsbase = 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 строго внутри сегмента с сохранением первой и последней точки; - по результату выполнить
resolveCurrentVacPath— возвращаемыйVacPathне содержит недорисовываемых сегментов. Прочие правила: thinning и transform выполняются per-subpath, никогда поперёк разрыва; SVG — один<path>с несколькимиM(без соединительных отрезков — смок-ассерт); вtrail_mode=cleaningintegration path скрывается вместе со следом по завершении уборки, вalways— остаётся. Формы входа: существующие ([{x,y}],[[x,y]],path.points) + XCMEpath.path: [[{x,y},…],…].
Арбитраж источников текущего пути (R2-B2 + R3-B3/R4-B2), нормативно. Единственная реализация выбора — pure helper:
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 helperareaCentroid(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), нормативно
residual— максимальная евклидова ошибка по matched-якорям в plan-единицах (семантика существующегоaffineResidual), не среднее.- Физическая ошибка:
residual / resolvedGridPitch * resolvedCellCm(см). - High residual — строго
> 40см (граница выбрана как ~полкорпуса робота: меньшее расхождение неотличимо от шума телеметрии). Текущий канвасный порогNORM_W * 0.05удаляется — процент холста означает разную физическую ошибку при разных cell_cm. - Отображение величины — общий форматтер единиц HA (metric/imperial), никакого хардкода «см» в строках.
- До подтверждения предложенная матрица НЕ записывается в config и не меняет сохранённую калибровку (текущее «сохранить → предупредить» — дефект, устраняется).
- Low residual: матрица сохраняется сразу, существующий success-тост.
- High → «Применить»: сохраняется именно предложенная матрица, success.
- High → «Подогнать вручную»: открывается fit-панель, инициализированная предложенной матрицей; сохранение — только по Apply внутри панели.
- 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)
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):
- registry-строка с
disabled_by→disabled(приоритет); - живой
hass.states[entityId]— положительное доказательство существования ДАЖЕ при authoritative snapshot без строки (легальный кейс: YAML-платформы без unique_id — XCME); stateunavailable→ source-статусunavailable, неmissing; - registry-строка без disable ИЛИ живой state → сущность существует;
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 |
Скоринг, независимый от порядка массивов:
- сохранённый
marker.vacuum.source— sticky: любой статус (включая unverified/unsupported) отображается как есть и НИКОГДА не подменяется автоматически; - camera с объектной позицией (
vacuum_position/robot_position); - иная сущность с объектной позицией;
- (Этап 2)
vacuum.*с валидной строкойposition; - диагностический кандидат: 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-атрибуты отсутствуют); сам факт глобального обнаружения подсказку НЕ включает:
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)
- Матрица §2 реализована буквально: каждая ячейка «да» покрыта юнит-фикстурой реального формата, каждая «нет» НЕ обещана в UI/доках.
- XCME: путь рисуется с разрывами; камера, не привязанная к устройству, выбирается из кандидатов и переживает reload.
- High-residual матрица НИКОГДА не попадает в config до явного «Применить» (тест ветки Cancel). 3-bis. Valetudo: автокалибровка проходит на outline-комнатах (якоря = area-центроиды, точные координаты в тесте); nonce-guard зелёный — обновления карты не рвут run и не теряют калибровку.
- Сохранённый источник sticky: missing показывает баннер и не подменяется; recovery без действий пользователя.
- Диагностика показывает пообъектные capability-строки; XCME-подсказка содержит все четыре атрибута; ссылка на доку фокусируема.
- Health-warnings по §5.2 (тест 7.5).
- Регресс: сохранённые матрицы и следы байт-неизменны (не мигрируют); НОВАЯ/повторная автокалибровка Dreame/демо может дать иную матрицу по areaCentroid-алгоритму, но обязана проходить существующие фикстуры в пределах утверждённого резидуала (R3-S4).
- 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. Решения владельца
- Выбор источника (B2/R3-B1): РЕШЕНО владельцем 2026-08-09 — picker подтверждён с формулировкой: «никаких ручных entity_id/YAML-полей; разрешён объяснимый picker автоматически найденных источников и отдельная расширенная секция camera для registry-less XCME». Принцип VACUUM.md уточняется этой формулировкой.
- Roomba, этап 2: подтвердить перенос и выбрать UX калибровки без комнат (две отметки vs ghost-квадрат) — уйдёт в ТЗ этапа 2. [решение]
- Roborock-поллер — без изменений, отложен до полевых отчётов. [решение из р.1 остаётся]
11. Изменения бэклога
Issue #9 (vacuum_json_id) закрывается как wontfix со ссылкой на B1; #10 (Roomba) переоформляется под этап 2 с полным скоупом; #6 дополняется контрактом Pt[][]; #7 — разделением якорь/bbox.