diff --git a/docs/specs/318-empty-controller-roster.md b/docs/specs/318-empty-controller-roster.md new file mode 100644 index 00000000..438b2f04 --- /dev/null +++ b/docs/specs/318-empty-controller-roster.md @@ -0,0 +1,294 @@ +# Issue #318 — активный контроллер без собственных сущностей следует `controls` + +- Дата: 2026-08-28 +- Тип: bug · приоритет P2 +- Оценка: пользовательская ценность 6/10 · сложность 3/10 · риск 4/10 +- Issue: [#318](https://github.com/Matysh/houseplan-card/issues/318) +- Связанные контракты: [#251](https://github.com/Matysh/houseplan-card/issues/251), + [#267](https://github.com/Matysh/houseplan-card/issues/267), + [#274](https://github.com/Matysh/houseplan-card/issues/274) +- Ветка: `issue/318-switch-render-state` + +Канонические документы: `docs/SCOPE.md`, `docs/ARCHITECTURE.md`, +`docs/DEVICE-PRESENTATION.md`, `docs/CONFIG-COMPATIBILITY.md`, +`docs/TOUCH-SUPPORT.md`, `docs/USER-GUIDE.md`, +`docs/USER-GUIDE.ru.md`, `docs/TESTING.md`. + +## 1. Сценарий и персона + +Житель дома смотрит в View или kiosk на физический настенный выключатель, +который управляет лампой либо группой через сохранённые `controls`. Home +Assistant содержит активную запись устройства, но интеграция не создаёт для +него ни одной собственной сущности. При переключении управляемого света +человек ожидает увидеть обычный нейтральный выключатель для `off` и жёлтый — +для `on`, а не постоянный признак недоступного устройства. + +Поверхности: основной план в View/kiosk, hosted Static и живой preview редактора +устройств. Это J1 «что происходит сейчас» и J3 «быстрое очевидное действие» из +`docs/SCOPE.md`. + +## 2. Что человек увидит до и после + +**До:** выключатель постоянно выглядит недоступным независимо от того, +включается или выключается управляемый свет. + +**После:** при включённой цели выключатель становится жёлтым, при выключенной +или недоступной цели — обычным нейтральным; приглушение сохраняется только там, +где Home Assistant действительно предоставляет собственные сущности +контроллера и все они не имеют живого состояния. + +## 3. Проблема и подтверждённый диагноз + +Полевой сценарий воспроизведён на `dev.houseplan.tech`. Сохранённый marker +`Wall Switch Kitchen` имеет активный `device:` binding, ноль собственных строк +entity registry и `controls: [light.wall_lights]`. + +`resolvePresentationSources()` корректно берёт `working/neutral` у +управляемой цели. Затем `resolveDevicePresentation()` классифицирует marker как +controller face и вызывает `controllerAvailability()`. Функция ищет хотя бы +одно живое состояние в `DevItem.entities`; пустой список безусловно даёт +`unavailable`. Presentation policy заменяет только availability агрегата, +поэтому target status обновляется внутри проекции, но класс `unavail` имеет +визуальный приоритет и скрывает и жёлтую, и нейтральную подложку. + +Обновление HA-состояний и repaint исправны: на приложенных кадрах одновременно +меняются лампы и комнатный счётчик. #274 также уже гарантирует одинаковый roster +и одну semantic generation для плана и preview. Дефект находится не в snapshot +continuity, а в неразличении двух случаев: «у контроллера нет канала телеметрии» +и «его существующие каналы сообщают unknown/unavailable». + +## 4. Зафиксированное решение владельца + +1. Активный физический `device:` binding с пустым собственным roster считается + доступным: отсутствие телеметрии не является доказательством offline. +2. Его `working/neutral` следует существующему resolved graph `controls`: + доступная цель `on` даёт жёлтую подложку, все доступные цели `off` — + нейтральную. +3. Если roster непуст, но ни одна собственная сущность не имеет живого + состояния, контроллер остаётся `unavailable` по контракту #251. +4. Явные `ha_disabled`, `orphaned`, virtual controller, partial controls, + безопасный no-op и приоритет alarm не меняются. +5. Plan, preview и hosted Static используют один результат общего presentation + resolver. + +Продуктовых вопросов не осталось. + +## 5. Скоуп + +### Входит + +- различение пустого и непустого собственного roster для активного + физического device-binding; +- доступная проекция пустого roster с target-derived `working/neutral`; +- сохранение `unavailable` для непустого roster без живых состояний; +- совпадение View, kiosk, hosted Static и device preview; +- актуализация строки S05 таблицы решений #267 и RU/EN документации; +- unit, production-bundle smoke и mutation evidence; +- оба changelog. + +### Не входит + +- считать запись device registry доказательством online, когда у устройства + есть собственные сущности; +- менять entity-bound markers: активная `entity:` привязка содержит точную + сущность и не образует пустой roster; +- новый badge, warning glyph, pulse, цвет или текст причины; +- изменение resolver управляемых целей, Toggle, confirmation или toast; +- изменение Glow, room fill, light statistics или service payload; +- исправление/миграция данных dev-стенда; +- persisted config, backend, schema/model version или registry API; +- расширение поведения на `unverified`, `ha_disabled` либо `orphaned` binding. + +## 6. Контракт поведения + +### 6.1. Термины + +`own roster` — `DevItem.entities`, то есть активные собственные сущности exact +binding после действующих registry-фильтров. `roster empty` означает длину 0, а +не список из сущностей без live state. + +`active physical device binding` выполняет одновременно: + +- `bindingKind === 'device'` либо `marker.binding` начинается с `device:`; +- `bindingStatus.kind === 'active'`; +- marker не virtual. + +Отсутствующий `bindingStatus`, `unverified`, `ha_disabled` и `orphaned` не дают +права применять fallback. Техническая реализация вправе выразить этот +предикат иначе, если наблюдаемый контракт остаётся тем же. + +### 6.2. Матрица availability/status + +При включённых live states и сохранённых внешних `controls`: + +| Binding и собственный roster | Управляемые цели | Итоговая проекция | +|---|---|---| +| active device, roster пуст | хотя бы одна доступная `on` | available + working; жёлтая | +| active device, roster пуст | все доступные `off` | available + neutral | +| active device, roster пуст | все unavailable/missing/отфильтрованы tombstone | available + neutral | +| active device, roster непуст, хотя бы одна own state живая | target `on` | available + working | +| active device, roster непуст, хотя бы одна own state живая | targets `off`/unavailable | available + neutral | +| active device, roster непуст, все own missing/unknown/unavailable | target `on` | unavailable; faded имеет приоритет над working | +| active device, roster непуст, все own missing/unknown/unavailable | targets off/unavailable | unavailable + neutral | +| virtual controller, roster пуст | target on/off/unavailable | действующий контракт virtual controller без изменений | +| ha-disabled/orphaned device, roster пуст | любое | действующий lifecycle-контракт без fallback active-device | + +Живым остаётся состояние, отличное после trim/lowercase от пустой строки, +`unknown` и `unavailable`. Числа, `on`, `off`, battery, LQI и update считаются +живыми, как в #251. + +Если target aggregate недоступен, controller override меняет availability на +`available`, но не создаёт ложный `working`: итог нейтрален. Если цель доступна +и включена, target-derived `working` сохраняется. + +### 6.3. Приоритеты + +- critical alarm собственной сущности остаётся выше availability/status; +- `live_states: false` и `static_icon` сохраняют нейтральную статичную подачу; +- HA-disabled/user-hidden/orphaned lifecycle не оживает от пустого roster; +- `controls` определяют status, но не становятся собственными сущностями; +- target tombstone сохраняет controller role по #274 и даёт нейтральное лицо; +- partial target group и исполнение действия не меняются; +- LQI/value badge/температура не создаются из пустого roster. + +### 6.4. A11y, hover и действия + +Доступный marker снова получает обычный hover/focus paint и a11y-state +`working` либо neutral вместо `unavailable`. Hit area, click/tap target, +confirmation и service calls не меняются. Touch не получает нового жеста; +View/kiosk остаются полностью поддержанными. + +## 7. UX + +Новых настроек и элементов нет. Изменение происходит автоматически после +обновления frontend: существующий marker перестаёт быть полупрозрачным и +использует уже знакомые нейтральную/жёлтую подложки. + +Preview редактора обязан показывать тот же результат для несохранённого draft, +построенного из того же active binding и полного sibling roster. Hosted Static +показывает ту же live проекцию, оставаясь неинтерактивным согласно своей +поверхности. Светлая/тёмная тема меняет только существующие theme tokens. + +## 8. Модель данных, compatibility и migration + +Persisted marker, `controls`, binding, config/model version, backend storage и +HA registry не меняются. Миграции нет. Старый frontend продолжит показывать +пустой controller roster приглушённым; новый применит правило при следующем +render/state tick без записи конфигурации. + +Fallback основан на уже вычисленном `bindingStatus` и не должен добавлять +registry fetch, polling либо серверное device-health состояние. + +## 9. i18n и документация + +Новых пользовательских строк нет, поэтому JSON-каталоги en/ru/de не меняются. +Обновляются: + +- `docs/DEVICE-PRESENTATION.md`: S05 разделяется на empty-active и + non-empty-without-live варианты; +- `docs/ARCHITECTURE.md`: уточняется семантика controller availability; +- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`: отсутствие собственных + сущностей у активного устройства не трактуется как offline; +- `docs/TESTING.md`: добавляется точная матрица регрессии; +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: короткий user-visible bullet со + ссылкой на #318. + +## 10. Критерии приёмки + +| AC | Требование | Доказательство | +|---|---|---| +| AC1 | Exact fixture `device:active`, own roster `[]`, `controls=[target]` даёт available+working для target on и available+neutral для off | `test/device-presentation.test.mjs` | +| AC2 | Та же fixture при target unavailable/missing и при target-marker tombstone остаётся available+neutral, без ложного working/pulse | unit matrix + production-bundle smoke | +| AC3 | Непустой roster, в котором все own states missing/unknown/unavailable, остаётся faded даже при target on; живые battery/LQI/update сохраняют #251 | существующие и новые negative units | +| AC4 | `ha_disabled`, `orphaned`, virtual controller, no-controls marker, static icon, live_states off и alarm сохраняют канонические строки таблицы решений | policy/presentation regression suite | +| AC5 | Plan и device preview для active empty-roster controller совпадают по `sourceKind`, `visual`, classes и a11y state; target on→off обновляет обе поверхности | расширенный `demo/smoke_wireless_controller_parity.mjs` | +| AC6 | Hosted Static использует ту же presentation policy; light/dark и desktop/touch не вводят новый layout/gesture | shared resolver unit + smoke/golden regression | +| AC7 | Config round-trip, controls и service-call payload не меняются; backend/model/schema untouched | diff audit + existing config/toggle tests | +| AC8 | Мутант, возвращающий empty active roster к `unavailable`, ловится целевым тестом; существующие мутанты #251/#274 продолжают ловиться | `scripts/mutation-gate.mjs` direct run | +| AC9 | Fast gates, выбранные targeted smokes, docs check, bundle sync/budget зелёные | точные команды в handoff issue | + +## 11. План реализации и автотестов + +1. В `controllerAvailability()` либо чистом соседнем policy helper различить + active physical device с roster `[]` и non-empty roster без live states. +2. Не менять `resolvePresentationSources()` и target graph: существующий + aggregate уже правильно вычисляет `working/neutral`. +3. Добавить unit-матрицу AC1–AC4 в `test/device-presentation.test.mjs` и при + необходимости чистый policy test для active-binding gate. +4. Расширить `demo/smoke_wireless_controller_parity.mjs` вторым exact fixture + без собственных registry rows; проверить plan/preview и on/off/unavailable. +5. Добавить узкий mutation guard empty-roster fallback; повторно прогнать + `controller-availability-follows-target` и мутанты #274. +6. Обновить канонические документы и оба changelog. +7. Перед `S7-code-review` выполнить минимум: + +```text +npm run typecheck +npm test +npm run build +npm run bundle:sync +npm run bundle:budget +node scripts/check-docs.mjs +node scripts/smoke-select.mjs --base origin/dev --head HEAD +node demo/smoke_wireless_controller_parity.mjs +node scripts/mutation-gate.mjs --only +node scripts/mutation-gate.mjs --only controller-availability-follows-target +node scripts/mutation-gate.mjs --only wireless-controller-loses-filtered-target-role +node scripts/mutation-gate.mjs --only wireless-controller-preview-drops-sibling-markers +``` + +Если `smoke-select` выберет дополнительные smokes, они также обязательны. +Golden baseline не принимается в реализации; полный `golden:verify` остаётся +предрелизным gate. + +## 12. Производительность, безопасность и privacy + +Проверка ограничена одним уже построенным marker: `O(1)` для пустого списка и +существующий `O(e)` для non-empty roster. Нельзя повторно строить light graph, +запрашивать registry/backend или вводить новый cache. + +Действие и service payload не меняются; недоступные цели по-прежнему +fail-closed. Новых сетевых запросов, разрешений, логируемых identifiers и +персональных данных нет. Решение не должно выводить entity/device IDs в UI. + +## 13. Риски и меры + +| Риск | Мера | +|---|---| +| Пустой roster ошибочно оживит disabled/orphaned marker | fallback требует точного `bindingStatus.kind === active`; lifecycle negative tests | +| Общий fallback ослабит #251 для event-only/unknown устройств | различать `length === 0` и non-empty; exact negative unit/mutant | +| Target unavailable станет ложным working | статус не синтезировать; сохранять target aggregate и проверить AC2 | +| Plan и preview снова разойдутся | production-bundle smoke сравнивает полный `ResolvedDevicePresentation` | +| Entity marker случайно получит fallback | ограничить active physical `device:` binding и проверить negative fixture | +| Изменится Toggle/Glow | не менять соответствующие resolver; existing suites + diff audit | + +## 14. Откат + +Откат — вернуть прежнюю ветку empty roster → `unavailable` и сопровождающие +документы/tests. Данных для обратной миграции нет: конфигурация не меняется. +Если до беты выяснится, что active device registry недостаточно как fallback, +issue возвращается в `S3-spec`; нельзя маскировать риск новым persisted flag без +отдельного продуктового решения. + +## 15. Release-артефакты + +- user-visible: **yes**; +- один короткий пункт со ссылкой на #318 в RU и EN changelog; +- release body упоминает исправление только если оно входит в публикуемую + beta/stable range; мелкие внутренние проверки отдельно не перечисляются; +- issue закрывает релиз-менеджер после зелёной опубликованной беты; +- новые screenshot/golden baseline не требуются, если semantic smoke и + существующая visual matrix зелёные без ожидаемой baseline-дельты. + +## 16. Принятые технические предположения — менять свободно + +1. Наиболее узкое место изменения — `controllerAvailability(hass, d)`; перенос + предиката в чистую presentation policy допустим, если источник истины один. +2. `bindingStatus` — достаточное техническое доказательство active binding; сам + HA device registry не объявляется runtime health API. +3. Existing `combineVisualSamples()` и controller-face override уже дают + правильный status; отдельный новый enum availability не нужен. +4. Existing #274 smoke дешевле и надёжнее расширить вторым сценарием, чем + создавать ещё один browser process. +5. Decision trace может получить отдельный внутренний ID для empty-roster + fallback; это developer-facing изменение без persisted schema и i18n. diff --git a/docs/specs/README.md b/docs/specs/README.md index 8e0ec669..3345bfb5 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -149,6 +149,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#265](https://github.com/Matysh/houseplan-card/issues/265) Единый контракт ссылочного шва импорта | [265-import-reference-seam.md](265-import-reference-seam.md) | | [#267](https://github.com/Matysh/houseplan-card/issues/267) Таблица решений для «лица» маркера | [267-device-presentation-decision-table.md](267-device-presentation-decision-table.md) | | [#274](https://github.com/Matysh/houseplan-card/issues/274) Беспроводной контроллер одинаково выглядит на плане и в preview | [274-wireless-controller-presentation-parity.md](274-wireless-controller-presentation-parity.md) | +| [#318](https://github.com/Matysh/houseplan-card/issues/318) Активный контроллер без собственных сущностей следует `controls` | [318-empty-controller-roster.md](318-empty-controller-roster.md) | | [#294](https://github.com/Matysh/houseplan-card/issues/294) Esc завершает текущую цепочку стен без удаления геометрии | [294-wall-esc-detach.md](294-wall-esc-detach.md) | ## P3