Files
houseplan-card/docs/specs/205-vacuum-trail-resume-grace.md
T
2026-08-19 20:54:56 +03:00

15 KiB
Raw Blame History

Issue #205 — продолжение следа после короткой остановки пылесоса

  • Дата: 2026-08-19
  • Тип: bug · приоритет P1
  • Оценка: пользовательская ценность 7/10 · ценность для разработки 5/10 · сложность 4/10 · риск 7/10
  • Issue: #205
  • Ветка: issue/205-vacuum-trail-grace

Канонические документы: docs/SCOPE.md, docs/VACUUM.md, docs/USER-GUIDE.ru.md, docs/TESTING.md.

1. Сценарий и персона

Владелец робота со станцией запускает уборку этажа. Робот несколько раз возвращается на док промыть швабры, выгрузить мусор либо ожидает после Pause, а затем продолжает то же задание на той же карте. Владелец ожидает видеть один накопленный след всей уборки, а не только путь после последнего визита на базу.

2. До и после

До: любой доступный non-moving state немедленно завершает current. Следующая точка безусловно начинает новый run. В default-режиме cleaning предыдущая часть вообще не рисуется; в always она становится полупрозрачной, а второй визит на док вытесняет самый ранний кусок из one-deep previous.

После: остановка по-прежнему скрывает след в режиме cleaning, но новая точка на той же карте в течение 30 минут возобновляет тот же current: старые и новые points остаются одним путём. Более долгая остановка или смена карты создаёт новый run как раньше.

3. Подтверждённая причина

TrailRecorder._sample() вызывает TrailBook.end_run() для любого state вне cleaning, returning, on. end_run() записывает epoch timestamp в current.ended. TrailBook.on_point() трактует любое truthy ended как безусловную границу и ротирует current → previous независимо от длительности остановки и map_id.

Переход станции cleaning → returning → docked → cleaning поэтому всегда разрывает run. Механизм vendor-neutral и затрагивает также paused, idle, краткий error и неизвестные доступные состояния. unavailable, unknown и отсутствие state уже нейтральны и сами run не завершают.

4. Scope

  • ввести backend-константу resume grace ровно 30 минут;
  • научить pure TrailBook.on_point() возобновлять ended current при той же карте и now - ended <= grace;
  • сохранить мгновенный end_run() и скрытие default trail во время остановки;
  • жёстко разрывать run при другой карте и после grace;
  • применять правило ко всем доступным non-moving states без vendor dialect;
  • сохранить Store format, decimation/cap, one-deep previous и event/save wiring;
  • покрыть pure book, recorder sequences, restart persistence и card rendering.

5. Non-scope

  • Tasshack/Dreame-specific атрибуты washing/drying/emptying;
  • task/session id, эвристики по координатам, площади, названию комнаты или integration path;
  • настройка длительности grace в UI/config и отдельное предупреждение;
  • хранение более двух runs, склейка уже разорванной legacy history или миграция Store;
  • изменение MOVING_STATES, puck visibility, calibration, path thinning, trail_mode или приоритета integration/server/local paths;
  • изменение source health monitor и semantics unavailable/unknown.

6. Контракт run lifecycle

Пусть TRAIL_RESUME_GRACE_S = 1800 и current имеет числовой ended.

  1. Новая валидная point при совпадающем map_id и 0 <= now - ended <= 1800 снимает ended (None) и дописывается в тот же points; started не меняется, previous не создаётся и не меняется.
  2. Ровно на границе 30:00 run продолжается. При now - ended > 1800 создаётся новый current с started = now, старый current становится previous.
  3. Несовпадающий map_id всегда начинает новый run независимо от времени.
  4. Если первая point после resume совпадает с последней сохранённой, дубль не добавляется, но снятие ended всё равно считается изменением: state должен быть сохранён и событие обновления отправлено.
  5. Отрицательная/нечисловая/неfinite разница либо malformed persisted ended не даёт права на resume и безопасно начинает новый run. Legacy ended:null остаётся активным run.
  6. Повторные non-moving samples не сдвигают начало окна: idempotent end_run() сохраняет timestamp первого подтверждённого stop.
  7. unavailable, unknown и отсутствие vacuum state не вызывают end_run(), не снимают ended и не сдвигают timestamp. Если до них был подтверждённый stop, окно продолжает идти по wall clock.
  8. Любой доступный state вне MOVING_STATES использует один контракт — тип остановки не сохраняется и не влияет на решение.

Неизбежный trade-off принят владельцем: если интеграция не даёт task id, две реально разные уборки на той же карте, начатые в пределах 30 минут, могут визуально склеиться. Не добавлять нестабильные эвристики для их угадывания.

7. Persistence, рестарт и frontend

Store version и shape не меняются: epoch ended, started, map_id и points уже сохраняются. После рестарта HA TrailBook читает timestamp и принимает то же решение по реальному elapsed time. Отдельный timer не нужен.

Пока robot non-moving, recorder сохраняет ended current, карточка в trail_mode:'cleaning' скрывает line как сейчас. После resume backend отдаёт тот же current с ended:null и полным points; frontend не склеивает runs и не получает новой ветки. В always возобновлённый current возвращается к обычному current style, а existing previous (если был до этого задания) остаётся тем же.

8. UX, accessibility, i18n и compatibility

Новых controls, текстов, notifications и locale keys нет. Визуально меняется только целостность уже включённого следа. Reduced motion, theme, touch, keyboard и screen-reader semantics не затрагиваются.

Старые Store records читаются без миграции. Downgrade снова разорвёт следующий ended run, но данные останутся валидны. Delete trail, marker removal, multi-floor source fan-out и map-id normalization не меняются.

9. Acceptance criteria

AC Критерий Доказательство
AC1 cleaning → docked 10 min → cleaning на той же карте даёт один current со всеми points и без нового previous. Pure TrailBook + recorder pytest.
AC2 Pause ровно 30:00 продолжается; 30:00 + epsilon создаёт новый run. Boundary pytest with explicit timestamps.
AC3 Смена map_id внутри grace всегда ротирует current → previous. Pure map matrix.
AC4 Resume с дубликатом последней point снимает ended, не добавляет point и возвращает changed=true. Focused pure test.
AC5 Два и более коротких dock/pause цикла не теряют ранние points и не вытесняют existing previous. Sequence regression.
AC6 unavailable/unknown/missing остаются нейтральными; repeated stop не продлевает grace. Recorder pytest.
AC7 Persisted ended run после реконструкции TrailBook возобновляется/разрывается по тому же окну; malformed timestamps fail closed. Store-shaped pure tests.
AC8 Default card после resume рисует полный current; во время dock скрывает его. always сохраняет правильные current/previous styles. Targeted production-bundle vacuum smoke with server payload.
AC9 Source health, two-floor fan-out, cap/decimation, delete and map-id suites остаются зелёными. Existing backend tests.
AC10 Старое unconditional rotation ловится мутантом. Mutation gate with focused pytest guard.
AC11 Рабочие gates зелёные. typecheck, unit, build, targeted smoke, pytest tests_backend; Linux HA harness in CI.

10. План реализации и тестов

Минимальная реализация живёт в custom_components/houseplan/trails.py: константа и узкая pure-проверка resume в начале TrailBook.on_point(). Сначала жёстко обрабатывается map mismatch; затем допустимый ended current открывается снова; иначе сохраняется нынешняя rotation. Возвращаемый changed объединяет факт resume и факт append, чтобы duplicate-point case всё равно дошёл до Store/event.

tests_backend/test_trails.py получает таблицу grace/map/malformed/duplicate и multi-stop. tests_backend/test_trail_recorder.py воспроизводит state sequences, neutral states и restart-shaped book. demo/smoke_vacuum.mjs либо отдельный targeted scenario доказывает frontend payload contract без vendor API.

Mutation entry возвращает условие if cur.get('ended') к безусловной rotation; focused backend guard обязан стать красным. Локально запускается полный доступный python -m pytest tests_backend -q; если Windows fcntl блокирует HA harness, фиксируется чистый pure/stub subset, а канонический полный результат берётся из Linux CI согласно PROCESS.md.

Golden не нужен: path continuity проверяется SVG path/points численно. Full smoke/golden/performance выполняется перед бетой.

11. Риски и меры

Риск Мера
Две реальные уборки склеятся Принятый 30-минутный предел; map switch всегда hard boundary.
Repeated dock продлевает окно бесконечно end_run() не переписывает truthy ended.
Duplicate point не сохранит resume Changed=true при снятии ended, AC4.
Рестарт изменит решение Epoch timestamp из Store, без process-local timer, AC7.
Vendor state не покрыт Единое правило для всех available non-moving states.
Backend исправлен, card теряет старые points Payload/render smoke AC8.

Проверка на point — O(1); асимптотика и cap не меняются. Security/privacy boundary прежний: новых данных, логов, сетевых запросов, permissions и services нет.

12. Release-артефакты и rollback

Изменение пользовательское. Implementation-коммит имеет User-Visible: yes и включает:

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md со ссылкой на #205;
  • docs/VACUUM.md и docs/USER-GUIDE.ru.md — 30-минутный resume contract и trade-off;
  • docs/TESTING.md — backend sequence/boundary/restart coverage;
  • docs/STATUS.md — фактическую release-линию;
  • backend tests, targeted frontend smoke, mutation entry и синхронные bundle copies, если build меняет tracked bundles.

Отдельные schema/migration, i18n, screenshot/golden, security и performance артефакты не нужны. Rollback — revert implementation-коммита; Store совместим, но следующие короткие остановки снова начнут новые runs.

13. Принятые предположения

  1. Grace считается по переданному now и сохранённому epoch ended; clock rollback fail-closed и не возобновляет run.
  2. Константа module-level, без конфигурации; точное имя helper техническое.
  3. Existing previous не меняется при resume current; только настоящий новый run заменяет его по нынешнему one-deep контракту.
  4. Vendor-specific fast-path откладывается до отдельной задачи с diagnostics fixture.
  5. Продуктовых вопросов больше нет: Q1–Q4 приняты владельцем по defaults.