15 KiB
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.
- Новая валидная point при совпадающем
map_idи0 <= now - ended <= 1800снимаетended(None) и дописывается в тот жеpoints;startedне меняется,previousне создаётся и не меняется. - Ровно на границе 30:00 run продолжается. При
now - ended > 1800создаётся новый current сstarted = now, старый current становится previous. - Несовпадающий
map_idвсегда начинает новый run независимо от времени. - Если первая point после resume совпадает с последней сохранённой, дубль не
добавляется, но снятие
endedвсё равно считается изменением: state должен быть сохранён и событие обновления отправлено. - Отрицательная/нечисловая/неfinite разница либо malformed persisted
endedне даёт права на resume и безопасно начинает новый run. Legacyended:nullостаётся активным run. - Повторные non-moving samples не сдвигают начало окна: idempotent
end_run()сохраняет timestamp первого подтверждённого stop. unavailable,unknownи отсутствие vacuum state не вызываютend_run(), не снимаютendedи не сдвигают timestamp. Если до них был подтверждённый stop, окно продолжает идти по wall clock.- Любой доступный 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. Принятые предположения
- Grace считается по переданному
nowи сохранённому epochended; clock rollback fail-closed и не возобновляет run. - Константа module-level, без конфигурации; точное имя helper техническое.
- Existing
previousне меняется при resume current; только настоящий новый run заменяет его по нынешнему one-deep контракту. - Vendor-specific fast-path откладывается до отдельной задачи с diagnostics fixture.
- Продуктовых вопросов больше нет: Q1–Q4 приняты владельцем по defaults.