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

48 KiB
Raw Blame History

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), строго в этом порядке:

  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:

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)

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-атрибуты отсутствуют); сам факт глобального обнаружения подсказку НЕ включает:

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.