# Issue #164 — активный цикл стиральной машины должен быть жёлтым Редакция: **вторая; замечания ревью учтены, вердикт зелёный** Дата: 2026-08-16 Тип: `bug` · приоритет: `P1` · пользовательская ценность: 8/10 · сложность: 5/10 · риск: 6/10 Issue: [#164](https://github.com/Matysh/houseplan-card/issues/164) Ветка: `issue/164-washer-active-cycle` Канонические документы: [SCOPE](../SCOPE.md), [TOUCH-SUPPORT](../TOUCH-SUPPORT.md), [USER-GUIDE](../USER-GUIDE.md), [USER-GUIDE.ru](../USER-GUIDE.ru.md), [CONFIG-COMPATIBILITY](../CONFIG-COMPATIBILITY.md). ## 1. Сценарий и персона Член семьи смотрит обычный View на телефоне или настенной kiosk-панели, пока стиральная машина выполняет программу. В Home Assistant у устройства есть несколько сущностей: питание, статус, этап, программа, температура и оставшееся время. Пользователь должен по маркеру на плане сразу увидеть, что прибор сейчас работает, не открывая карточку устройства. Это основной J1 из `docs/SCOPE.md`: «показать весь дом и что происходит прямо сейчас». View и kiosk на touch являются полностью поддержанными поверхностями. ## 2. Что человек увидит до и после **До:** при `Power=on`, `Status=start`, `Stage=Rinse` и ненулевом оставшемся времени маркер стиральной машины остаётся нейтральным. Карточка устройства показывает активный цикл, но план его не отражает. **После:** явный активный lifecycle-статус составного прибора делает маркер жёлтым по действующему виду «работает сейчас». Когда статус становится idle, paused, stopped или terminal, жёлтая подложка снимается. Одно лишь включённое питание по-прежнему не означает работу. Новых цветов, иконок, настроек или эффектов задача не вводит. ## 3. Проблема и подтверждённая причина 1. `resolvedDeviceStateEntities()` выбирает одну функциональную роль устройства. Когда whole-device домена и semantic binary нет, наличие switch переводит resolver в switch-ветку, где предпочтение получает выделенный `Power`. 2. `entityVisualSamplesForDevice()` намеренно проецирует `Power=on` составного устройства как neutral: питание может быть включено при бездействующем приборе. Эта защита от false positive должна сохраниться. 3. Более точные lifecycle-сущности (`Status=start`) из того же HA device не участвуют в visual samples после выбора Power. 4. Текущий словарь actual work знает `running`, `washing`, `rinsing` и похожие состояния, но не значение `start` из пользовательского отчёта. 5. Итог — false negative: защита от ложного жёлтого состояния скрывает реальную работу, хотя интеграция предоставляет отдельный статус цикла. Проблема относится не к внешнему виду карточки и не к конкретной модели `Front Load Washer Unknown (0)`, а к общему выбору семантического источника у составных HA devices. ## 4. Scope В задачу входят: 1. строгий generic resolver явной appliance lifecycle-сущности в уже распознанной составной топологии с выделенным Power; 2. её приоритет над auxiliary switches и над нейтральным composite `Power=on` без изменения семантики lone relay; 3. отдельное lifecycle-расширение actual-work словаря и дополнение общего terminal/idle словаря минимальными значениями, необходимыми для отчёта и симметричного завершения цикла; 4. явная матрица совместной работы lifecycle и выделенного Power; 5. одинаковый результат в full card, `houseplan-space-card`, desktop View, touch View и kiosk; 6. защита существующей семантики alarms, climate, media, lights, covers, vacuums, automations, lone relays и composite Power; 7. unit и targeted golden coverage; 8. оба changelog и актуализация пользовательского описания working-состояния; 9. синхронная сборка трёх поставляемых bundle-копий. ## 5. Non-scope В задачу не входят: - интеграционно- или модельно-специфичные правила для Xiaomi/MiOT либо другого производителя; - вывод работы из friendly name самого устройства, модели или integration domain; - предположение «Power=on всегда означает работает»; - вывод работы из выбранных Mode/Program, температуры, скорости отжима, количества средства или любого произвольного ненулевого sensor; - использование оставшегося времени как самостоятельного признака: некоторые интеграции сохраняют полную длительность либо последнее значение после цикла; - использование Stage/Phase как самостоятельного авторитетного статуса в этой задаче: последний этап также может оставаться stale после завершения; - новый пользовательский selector источника состояния; - новые marker settings, поля server config, localStorage или миграция; - изменение цвета/тени/формы действующей жёлтой подложки либо pulse-системы; - изменение more-info/device card, реестра HA или service calls; - backend, новые websocket endpoints и управление устройством; - обобщённый конструктор vendor-state mappings — это отдельная продуктовая задача, если строгого generic-контракта окажется недостаточно. ## 6. Контракт выбора lifecycle-роли ### 6.1. Порядок ролей Действующий resolver сохраняет приоритеты whole-device доменов, semantic binary и lone relay. Новая appliance lifecycle-роль включается только тогда, когда существующее топологическое правило уже распознало составной прибор с выделенным Power: среди entities больше одного uncategorised switch, а выбранный switch подтверждён `isDevicePowerSwitch()`. В этой ветке порядок такой: 1. canonical whole-device domain (`vacuum`, `climate`, `cover`, ...); 2. semantic binary (`running`, `power`, presence/contact/alarm, ...); 3. строгая appliance lifecycle-сущность + выделенный Power как gate; 4. действующий composite Power fallback; 5. passive fallback. Так lifecycle не перехватывает устройство, для которого HA уже предоставляет более сильную стандартную state machine. Если топология не распознана как composite Power, существующий representative switch остаётся источником: одиночное реле `on` не теряет working из-за соседнего `Status=connected`, `Status=idle` или другого lifecycle-похожего sensor. ### 6.2. Что считается явной lifecycle-сущностью Роль определяется только по registry/state metadata конкретной сущности: - `translation_key`, `original_name`, registry `name`; - object id entity; - при отсутствии registry-имени — `friendly_name` state. Кандидаты ищутся среди всех entities устройства, кроме явно помеченных `entity_category: config`. Обычная uncategorised сущность предпочтительнее `diagnostic`, но точный lifecycle в diagnostic допустим: наличие отдельного Power не должно скрывать от resolver точный `Status` только из-за категории. После lower-case, trim и нормализации пробелов/`-`/`_` допускаются только точные lifecycle-роли или конечные сегменты: - `status` / `device_status` / `machine_status`; - `run_state` / `running_state` / `machine_state`; - `job_state` / `operation_state` / `activity_state`. Русские отображаемые имена (`Статус`, `Состояние работы`) в первой итерации не являются доказательством lifecycle-роли. Локализованный `friendly_name` не мешает выбору, если canonical `translation_key`, `original_name`, registry name или object id уже доказывает роль, но сам по себе роль не создаёт. Конечный сегмент `_status` матчится, поэтому до allowlist применяется явный connectivity stop-list. Кандидаты, чьи нормализованные metadata/object-id сегменты содержат `wifi`, `connection`, `signal` или `battery`, исключаются: `wifi_status`, `connection_status`, `signal_status` и `battery_status` не являются lifecycle прибора. Общий токен `state` без уточнения, substring внутри произвольного слова и эвристика по имени всего устройства запрещены. `mode`, `program`, `stage`, `phase`, `cycle_time`, `remaining_time` lifecycle-ролью #164 не являются. Если найдено несколько lifecycle-кандидатов, выбирается детерминированно: 1. `run_state` / `job_state` / `operation_state` / `activity_state`; 2. `machine_state` / `running_state`; 3. `status`. Внутри одного уровня сохраняется существующий visible-first и registry order. Live state не меняет выбранную сущность: временное `unavailable` не должно ретаргетить маркер на другой peer и создавать скачущую семантику. ## 7. Контракт actual-work значений Сравнение state и поддерживаемых action attributes остаётся case-insensitive и whitespace-insensitive. В одном модуле хранятся два уровня working vocabulary: 1. базовый `WORKING_STATES` без изменений — для generic entity projection и действующих action attributes; 2. `LIFECYCLE_WORKING_STATES` = базовый набор плюс минимальный набор lifecycle-глаголов, применяемый **только** к выбранной appliance lifecycle-роли: - `start`, `started`, `run`, `active`, `in_progress`; - короткие формы физических этапов, уже представленных длительными формами: `wash`, `rinse`, `spin`, `dry`. Существующий общий `IDLE_STATES` сохраняется и получает terminal-пары: - `stop`, `end`, `done`, `inactive`. `paused` остаётся неработающим состоянием. Глобальное расширение idle-набора допустимо и применяется по действующим веткам, включая climate; глобальное расширение базового working-набора запрещено. `unknown`, `unavailable`, пустое и missing не становятся working. Новые active-значения применяются только к уже выбранной lifecycle-роли и не расширяют классификацию generic sensors либо action attributes. Поэтому `dry`, `start` или `active` у устройства без lifecycle-роли остаются neutral. `wash`, `rinse`, `spin` и `dry` описывают допустимое **значение выбранной lifecycle-роли**; сущность Stage/Phase сама по себе по-прежнему не является authority и не сканируется. Неоднозначные значения вроде `normal`, `auto`, `eco`, `mixed_wash`, чисел и температур остаются neutral. ## 8. Матрица lifecycle + Power Новая матрица применяется только к топологии, которую существующее правило распознало как составной прибор с выделенным Power. В ней Power остаётся availability/lifecycle gate, но не источником actual work: | Power | Явный lifecycle | Итог | |---|---|---| | `on` | active (`start`, `washing`, ...) | available + yellow working/running | | `on` | idle/terminal (`idle`, `stop`, `done`, ...) | available + neutral | | `on` | unknown/unavailable/missing | available + neutral | | `on` | lifecycle отсутствует | действующий composite Power fallback: available + neutral | | `off` | любое, включая stale active | действующий Power-off вид: unavailable/faded + neutral | | unavailable/missing | любое | unavailable/faded + neutral | Auxiliary switches не могут сделать прибор working и не перебивают выбранный lifecycle. Alarm critical sources сохраняют действующий высший визуальный приоритет над working. За пределами распознанной composite Power топологии новая роль не меняет источник состояния. В частности, lone relay `on` остаётся working даже при соседнем lifecycle-похожем sensor с neutral/unknown значением; устройство без выделенного Power следует прежнему resolver и не получает новых lifecycle-specific active tokens по контракту #164. ## 9. Presentation и UX - Результат использует существующий `status: working` и существующую жёлтую подложку; новые CSS classes не нужны. - В display mode `Icon + state and activity` активный lifecycle также даёт существующий continuous running pulse. В остальных display modes действует нынешний контракт показа/подавления ordinary activity; жёлтая подложка не зависит от включения pulse. - `Always static icon` продолжает подавлять state-driven visual целиком. - Preview в Device editor объясняет тот же итог через существующую строку «Yellow plate: the device is working now». - Full card и static space card используют один resolver и не получают расходящихся списков состояний. - Никаких новых действий, focus-переходов, hover-контрактов или настроек нет. ## 10. Модель данных, compatibility и миграция Server config, marker config, layout, localStorage, backend schema и экспорт/ импорт не меняются. Новых compatibility-полей и schema version нет. Исправление вычисляется из текущего HA snapshot. Сохранённые планы не переписываются; новая версия читает старые данные, старая версия читает данные после новой без изменений. Прямая и обратная миграция не нужны. ## 11. i18n, accessibility и touch Новых пользовательских строк и i18n-ключей en/ru не требуется: существующие working reason и pulse accessibility label уже описывают результат. Touch editor: **не затронут**. Touch View и kiosk: **полностью поддержаны и release-blocking** — при одном HA snapshot они обязаны показать тот же yellow/ neutral результат, что desktop View. Новых жестов или hit targets нет. При `prefers-reduced-motion` жёлтая подложка остаётся, а existing pulse деградирует по текущему accessibility-контракту; #164 его не меняет. ## 12. Архитектурные границы реализации Предполагаемые файлы: - `src/devices.ts` — распознавание и выбор lifecycle role; - `src/device-visual.ts` — active/idle vocabulary и совместная Power-матрица; - при необходимости `src/device-presentation.ts` — только передача общего resolved role, без второго classifier; - `test/devices.test.mjs`, `test/device-visual.test.mjs`, `test/device-presentation.test.mjs`; - deterministic visual fixture и targeted golden scenario; - `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, оба changelog. Обязательные инварианты: 1. один модуль classifier владеет базовым working-набором, его scoped lifecycle-расширением и общим idle-набором; второй список в presentation запрещён; 2. один role resolver используется full и static cards; 3. выбор lifecycle не зависит от текущей доступности/значения и не скачет между peers на state update; 4. анализ выполняется по уже построенному списку entities устройства, без полного сканирования `hass.states` для каждого marker; 5. integration/model/device-name исключения запрещены; 6. Power-off и alarm precedence не размножаются отдельными render-ветками; 7. lifecycle override существует только внутри уже распознанной composite Power топологии и не меняет lone relay/обычный switch fallback. ## 13. Критерии приёмки - **AC1 (`unit`):** device-role resolver на fixture из отчёта (`Power`, `Status`, `Stage`, `Mode`, `Program`, `Cycle Time`, Temperature и auxiliary switches) детерминированно выбирает lifecycle `Status` плюс Power gate, а не auxiliary switch; uncategorised Status выигрывает у diagnostic, config исключается, а registry order и локализованный friendly name не меняют результат при наличии canonical metadata. Без canonical metadata русские `Статус`/`Состояние работы` роль не создают; `wifi_status`, `connection_status`, `signal_status` и `battery_status` всегда исключаются. - **AC2 (`unit`):** `Status=start` при `Power=on` даёт `{ availability: available, status: working, activity: running }`; те же assertions проходят для существующих длительных и новых коротких active форм независимо от регистра и внешних пробелов. - **AC3 (`unit`):** при `Power=on` значения `idle`, `paused`, `stop`, `done`, `finished` дают available + neutral; `Power=off/unavailable` подавляет даже stale active lifecycle и сохраняет действующий faded neutral вид. - **AC4 (`unit`):** в распознанной composite Power топологии `Power=on` без явной lifecycle-сущности остаётся neutral; Mode/Program/Stage/positive remaining time и auxiliary switches сами по себе не создают working. Generic sensor со state `dry`, `start` или `active` у устройства без выбранной lifecycle-роли также остаётся neutral. - **AC5 (`unit`):** при `Power=on` временно unavailable lifecycle остаётся выбранной ролью и даёт neutral, не ретаргетясь на peer. Топологии без выделенного composite Power следуют прежнему resolver: новые lifecycle-specific tokens их состояние не меняют. - **AC6 (`unit`):** lone relay `on` плюс lifecycle-похожий sensor со значением `connected`, `idle` или unavailable по-прежнему даёт working от relay; semantic binary остаётся выше lifecycle. Остальные регрессии сохраняют climate actual action/fallback, passive media, vacuum cleaning/returning, cover transition, automation enabled, alarm priority и static display suppression. - **AC7 (`unit` + `ревью кода`):** full card и `houseplan-space-card` получают один и тот же resolved presentation для active/idle washer snapshot; preview использует тот же reason без нового classifier. - **AC8 (`golden`):** synthetic composite washer добавлен в `demo/fixtures/visual-matrix.mjs`; active snapshot визуально имеет существующую жёлтую подложку, а парный idle snapshot — нейтральный. Сцены перечислены в `GOLDEN_SCENARIOS`, `GOLDEN_MATRIX_VERSION` увеличен; эталон не содержит реальных пользовательских данных и проверяется по обычному baseline-review процессу. - **AC9 (`ревью кода`):** desktop View, touch View и kiosk не имеют отдельных state branches; поведение не зависит от pointer/viewport и не меняет editor touch safety floor. - **AC10 (`unit` + `ревью кода`):** classifier не читает integration/model/ device friendly name, не вызывает HA services и не создаёт config writes. - **AC11 (`unit` + `performance smoke`):** resolver обходит только entities текущего устройства линейно, не добавляет timers, subscriptions, DOM layers или полный `hass.states` scan на marker; действующий performance budget не ухудшается. - **AC12 (`build` + `ревью кода`):** RU/EN user guide и changelog описывают активный lifecycle и сохранённую нейтральность одного Power; три bundle-копии после build побайтно совпадают. ## 14. План автотестов и гейтов ### Unit 1. Добавить device fixture, повторяющий состав сущностей пользовательского отчёта, с canonical registry metadata и локализованными display names. 2. Покрыть role priority и deterministic tie-break независимо от registry order. 3. Таблично покрыть базовый working-набор, scoped lifecycle-расширение, общий idle-набор и Power-матрицу из раздела 8. 4. Оставить негативную матрицу Mode/Program/Stage/time/aux switches и добавить generic `dry`/`start`/`active` без lifecycle-роли. 5. Добавить lone relay `on` с neutral lifecycle-похожим peer и connectivity `_status` candidates. 6. Расширить regression tests существующих device roles и presentation modes. ### Mutation gate (#85) До код-ревью исполнитель вручную вносит каждый мутант по одному и фиксирует, что указанный тест краснеет; после возврата корректного кода весь unit suite снова зелёный: 1. lifecycle-specific tokens добавлены в базовый working-набор вместо scoped набора → краснеет `keeps lifecycle-only active tokens neutral outside lifecycle role` (AC4); 2. снят Power-off gate для stale `Status=start` → краснеет `suppresses stale active lifecycle when composite Power is off` (AC3); 3. lifecycle-роль поднята выше semantic binary → краснеет `keeps semantic binary ahead of appliance lifecycle` (AC6); 4. unavailable lifecycle ретаргетится на соседнюю сущность → краснеет `keeps lifecycle role identity stable while live state is unavailable` (AC5); 5. в `device-presentation`/space-card добавлен второй vocabulary/classifier → краснеет `shares one lifecycle presentation between full and space cards` (AC7). ### Golden В `demo/fixtures/visual-matrix.mjs` добавляются synthetic active и idle варианты одного составного прибора. Оба capture регистрируются в `GOLDEN_SCENARIOS`, а `GOLDEN_MATRIX_VERSION` увеличивается. Меняется только уже существующая working-подложка; новые visual tokens не принимаются. ### Цикл реализации и pre-beta В реализации штатно запускаются `typecheck`, unit и build. Перед бетой по действующему процессу запускаются golden verify, browser smoke и performance. Backend/HA harness не требуется, потому что Python и websocket contract не меняются. ## 15. Производительность и security Целевой overhead — `O(E_device)` на уже существующем resolver pass, где `E_device` — несколько registry entities одного HA device. Нельзя добавлять вложенный полный проход по всем HA entities/states, polling, timers или новый render layer. Отдельный benchmark не нужен; штатный performance smoke остаётся release gate. Security boundary не меняется: используются только уже доступные read-only registry/state snapshots. Новых service calls, permissions, URLs и HTML нет; отдельный security artifact не требуется. ## 16. Риски 1. **Ложный yellow от lifecycle-глагола у generic sensor.** Базовый working vocabulary не расширяется, lifecycle tokens scoped выбранной ролью; AC4 и первый мутант защищают границу. 2. **Ложный yellow от режима/программы.** Закрывается строгим role allowlist и AC4. 3. **Stale stage после завершения.** Stage не является authority в #164; terminal Status и Power-off закрывают цикл. 4. **Ложный neutral из-за слишком узкого metadata matcher.** Fixture покрывает object id, translation key и original name. Русское отображаемое имя без canonical metadata намеренно не поддерживается первой итерацией. 5. **Connectivity status ошибочно принят за lifecycle.** Stop-list и AC1 исключают wifi/connection/signal/battery candidates. 6. **Power-off проигрывает stale Status=start.** Явная матрица и AC3 задают жёсткий gate. 7. **Регрессия lone relay или soundbar/composite switches.** Lifecycle override ограничен распознанной composite Power топологией; AC4/AC6 защищают обе стороны границы. 8. **Retarget на state update.** Выбор роли опирается на metadata, не live значение; AC5 защищает identity. 9. **Расхождение full/static card.** Запрещён второй classifier, AC7 проверяет один resolved presentation. ## 17. Release-артефакты Пользовательски видимый реализационный коммит обязан содержать: - `docs/CHANGELOG.md` — EN bug-fix bulletin со ссылкой на #164; - `docs/CHANGELOG.ru.md` — эквивалентный RU bulletin; - `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md` — уточнение: явный active lifecycle делает составной прибор working, одно Power — нет; - unit regression fixture и targeted synthetic golden; - синхронные `dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js` и `demo/srv/assets/houseplan-card.js`. Новых screenshots документации, backend, migration и security artifacts не требуется. Golden baseline принимается только по действующему release-review процессу. Перед бетой идут полный browser smoke, golden verify и performance. ## 18. Откат Откат — обычный revert classifier/resolver, тестов, документации, changelog и синхронных bundle snapshots. Persisted data не меняются, поэтому очистка cache, обратная миграция и feature flag не нужны. После отката составной прибор снова может оставаться neutral во время цикла; остальные устройства возвращаются к прежнему resolver без изменения данных. ## 19. Принятые предположения — можно менять без пересмотра продукта 1. Точные helper names и то, живёт ли metadata matcher в `devices.ts` или `device-visual.ts`, являются техническим выбором. 2. `Status=start` — авторитетный active lifecycle из приложенного отчёта; `Stage=Rinse` и оставшееся время используются как диагностическое подтверждение, но не как самостоятельные authority signals. 3. Строгий словарь ролей и значений предпочтительнее fuzzy/vendor matching; неподдержанный новый dialect должен становиться отдельным fixture/issue, а не поводом ослаблять matcher без доказательства. 4. Lifecycle и Power могут быть представлены двумя resolved samples либо одним составным result; наблюдаемая матрица раздела 8 обязательна, форма — нет. 5. Golden расширяет существующую `demo/fixtures/visual-matrix.mjs`, регистрирует обе сцены в `GOLDEN_SCENARIOS` и увеличивает `GOLDEN_MATRIX_VERSION`; реальные screenshot/user data в репозиторий не попадают.