From 5c09591ce9efabe35d1c661f21e12a66d3136043 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Wed, 19 Aug 2026 20:13:17 +0300 Subject: [PATCH] docs: specify vacuum trail resume grace Issue: #205 User-Visible: no --- docs/specs/205-vacuum-trail-resume-grace.md | 196 ++++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 197 insertions(+) create mode 100644 docs/specs/205-vacuum-trail-resume-grace.md diff --git a/docs/specs/205-vacuum-trail-resume-grace.md b/docs/specs/205-vacuum-trail-resume-grace.md new file mode 100644 index 00000000..cb99bf4a --- /dev/null +++ b/docs/specs/205-vacuum-trail-resume-grace.md @@ -0,0 +1,196 @@ +# Issue #205 — продолжение следа после короткой остановки пылесоса + +- Дата: 2026-08-19 +- Тип: bug · приоритет P1 +- Оценка: пользовательская ценность 7/10 · ценность для разработки 5/10 · сложность 4/10 · риск 7/10 +- Issue: [#205](https://github.com/Matysh/houseplan-card/issues/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. diff --git a/docs/specs/README.md b/docs/specs/README.md index 5d1a038e..02d6d219 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -53,6 +53,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#166](https://github.com/Matysh/houseplan-card/issues/166) Солнечные лучи зеркально учитывают направление севера | [166-sun-north-rotation.md](166-sun-north-rotation.md) | | [#167](https://github.com/Matysh/houseplan-card/issues/167) Экспорт «только планировка» | [167-plan-only-export.md](167-plan-only-export.md) | | [#170](https://github.com/Matysh/houseplan-card/issues/170) HA-устройство не привязывается к комнате без HA-зоны | [170-room-without-area.md](170-room-without-area.md) | +| [#205](https://github.com/Matysh/houseplan-card/issues/205) Продолжение следа после короткой остановки пылесоса | [205-vacuum-trail-resume-grace.md](205-vacuum-trail-resume-grace.md) | ## P2