docs: specify vacuum trail resume grace

Issue: #205
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-19 20:54:56 +03:00
parent 5dc9016645
commit 5c09591ce9
2 changed files with 197 additions and 0 deletions
+196
View File
@@ -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.
+1
View File
@@ -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