From 903bc0a345294e199cc8d84e6e57c12ae52bb702 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 23 Aug 2026 06:49:55 +0300 Subject: [PATCH] docs(spec): separate controller and target availability Issue: #251 User-Visible: no --- .../251-controller-target-availability.md | 355 ++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 356 insertions(+) create mode 100644 docs/specs/251-controller-target-availability.md diff --git a/docs/specs/251-controller-target-availability.md b/docs/specs/251-controller-target-availability.md new file mode 100644 index 00000000..85af0219 --- /dev/null +++ b/docs/specs/251-controller-target-availability.md @@ -0,0 +1,355 @@ +# Issue #251 — доступность контроллера не наследуется от управляемой цели + +- Дата: 2026-08-23 +- Тип: bug + polish · приоритет P2 +- Оценка: пользовательская ценность 7/10 · сложность 4/10 · риск 4/10 +- Issue: [#251](https://github.com/Matysh/houseplan-card/issues/251) +- Связанные контракты: [#107](https://github.com/Matysh/houseplan-card/issues/107), + [#174](https://github.com/Matysh/houseplan-card/issues/174), + [#178](https://github.com/Matysh/houseplan-card/issues/178) +- Ветка: `issue/251-controller-target-availability` +- Статус ТЗ: на ревью + +Канонические документы: `docs/SCOPE.md`, `docs/ARCHITECTURE.md`, +`docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`, +`docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, `docs/TESTING.md`. + +## 1. Сценарий и персона + +Житель дома смотрит на план с батарейным беспроводным выключателем, который +управляет лампой или световой группой. Сам выключатель остаётся в Zigbee-сети: +его батарея и LQI обновляются, но управляемая лампа временно недоступна. + +Человек ожидает, что внешний вид маркера отвечает на вопрос «доступен ли сам +выключатель», а жёлтая подложка — «работает ли управляемый свет». При нажатии на +маркер недоступной цели он должен получить понятное объяснение безопасного +no-op, а не молчание. + +Поверхности: интерактивные View и kiosk; общий presentation contract также +обязан совпасть в hosted Static и preview редактора устройств там, где включены +live states. + +## 2. Что человек увидит до и после + +**До:** живая батарейная кнопка становится полупрозрачной, когда недоступна её +лампа, и нажатие ничего не делает и ничего не объясняет. + +**После:** живая кнопка остаётся обычной нейтральной, а не жёлтой; нажатие +показывает «Цель „…“ недоступна — действие не выполнено». Полупрозрачность +появляется только когда нет живого состояния у самого контроллера. + +## 3. Проблема и подтверждённый диагноз + +`resolvePresentationSources()` для маркера с внешними `controls` строит +`sourceKind: controls` и передаёт в `combineVisualSamples()` состояния целей. +Если все цели имеют `unknown` / `unavailable` либо отсутствуют в `hass.states`, +aggregate получает `availability: unavailable`, после чего общий presentation +добавляет класс `unavail`. Собственные `DevItem.entities` контроллера в этой +ветке не участвуют, поэтому живые `battery` и `linkquality` ничего не меняют. + +Действие уже fail-closed: `resolveToggleIntent()` исключает недоступные цели, +хранит их имена и причины, а `toggleOperation()` не создаёт service call, когда +доступных целей нет. `_clickDevice()` завершает такую ветку ранним `return`, +поэтому пользователь не получает объяснения. + +HA не имеет единого device state. Решением владельца доступность контроллера +выводится отдельно из его собственных активных entity states. + +## 4. Зафиксированное решение владельца + +1. Внешний вид маркера не наследует доступность управляемой цели. +2. `working/on` по-прежнему наследуется от доступных управляемых целей: хотя бы + одна включённая цель делает контроллер жёлтым. +3. Если ни одна цель не доступна, target-derived состояние нейтрально и не + может сделать контроллер жёлтым. +4. Полупрозрачность означает недоступность самого привязанного контроллера. +5. Контроллер доступен, если хотя бы одна его собственная активная сущность + имеет непустое состояние, отличное от `unknown` и `unavailable`. + Диагностические `battery` / `linkquality` и `update` участвуют; `controls` — + нет. +6. Если доступных целей для toggle нет из-за их недоступности, нажатие не + отправляет команду и показывает локализованное уведомление с именем цели и + фактом, что действие не выполнено. +7. Новый бейдж или отдельный постоянный индикатор недоступной цели не вводится. + +Продуктовых вопросов не осталось. + +## 5. Скоуп + +### Входит + +- отдельное вычисление availability собственного HA-контроллера; +- target-derived `working/on` без target-derived `unavailable`; +- battery/LQI/update как допустимые доказательства доступности контроллера; +- нейтральный вид при живом контроллере и полностью недоступной группе; +- полупрозрачный вид при полностью недоступных собственных сущностях даже при + доступной или включённой цели; +- виртуальные контроллеры с `controls` как всегда доступные, поскольку у них + нет HA-устройства, которое можно объявить offline; +- уведомление для all-unavailable/missing/HA-disabled configured targets; +- повторная проверка доступности при подтверждении toggle; +- View/kiosk, shared presentation surfaces, i18n, accessibility, unit, smoke и + semantic golden coverage; +- документация и changelog RU/EN. + +### Не входит + +- новый бейдж, warning glyph, цвет или pulse для недоступной цели; +- изменение Glow, Light fill, комнатной статистики или spatial ownership; +- изменение групповой семантики «any on → all off, all off → all on»; +- service call в недоступную, отсутствующую или HA-disabled цель; +- уведомление о каждом пропуске, если доступная часть группы была выполнена; +- изменение quiet no-op для secure, unsupported или вообще не настроенной цели; +- новый HA device-health API, ping или network probe; +- изменение long-press, right-click, info card либо редактора controls; +- persisted schema, migration, backend или публикация hidden Iso. + +## 6. Контракт presentation + +### 6.1. Два независимых факта + +Для `sourceKind: controls` вычисляются отдельно: + +```text +controllerAvailability = availability(own active DevItem.entities) +targetStatus = status(available resolved controls) +``` + +Итоговое лицо использует availability первого и `working` / `neutral` второго. +Цели не входят в own entity set даже если находятся в том же HA device. + +Собственная сущность считается живой, если запись состояния существует и +`String(state).trim().toLowerCase()` не равна пустой строке, `unknown` или +`unavailable`. Значения `on`, `off`, `idle`, числовые строки, battery, LQI и +состояние update являются живыми. Registry row без live state недостаточна. + +Матрица обязательного результата при включённых live states: + +| Собственный контроллер | Управляемые цели | Итог | +|---|---|---| +| battery=100, LQI=164 | все `unavailable` | available + neutral | +| хотя бы одна own entity живая | хотя бы одна target `on` | available + working | +| хотя бы одна own entity живая | все target `off` | available + neutral | +| все own missing/unknown/unavailable | target `on` | unavailable; класс `unavail` имеет приоритет над working | +| все own missing/unknown/unavailable | все targets unavailable | unavailable + neutral | +| virtual controller без own entities | все targets unavailable | available + neutral | + +`event.* = unknown` само по себе не доказывает online. Если других живых +собственных сущностей нет, HA-контроллер недоступен. Для exact `entity:` binding +own set содержит только сущности этой привязки; для `device:` — его активный +`DevItem.entities` после действующих registry/ownership filters. + +### 6.2. Приоритеты и существующие исключения + +- критический alarm собственной сущности сохраняет высший приоритет; +- при `live_states: false` остаётся существующее статичное нейтральное лицо; +- display `static_icon` по-прежнему намеренно игнорирует все live states; +- HA-disabled/user-hidden markers сохраняют текущую видимость и editor-preview + семантику; +- media-player `off` и composite Power contracts вне controls-ветки не + меняются; +- partial target availability: доступная часть определяет working и действие, + недоступная часть игнорируется так же, как до задачи. + +Glow, room fill и statistics продолжают читать resolved light graph целиком. +Разделяется только presentation availability контроллера. + +## 7. Контракт действия и уведомления + +### 7.1. Полностью недоступная configured group + +Если explicit Toggle имеет сохранённые цели, но `toggleOperation(intent)` не +существует и все пропуски объясняются `unavailable`, `missing` или +`ha-disabled`, клик/тап: + +1. не вызывает `callService` / `callWS`; +2. не запускает press feedback; +3. не открывает info card или confirmation dialog; +4. показывает существующий локальный House Plan toast с `role="alert"` и + `aria-live="assertive"`. + +Точный текст: + +| Случай | RU | EN | +|---|---|---| +| одна цель | `Цель «{name}» недоступна — действие не выполнено` | `Target “{name}” is unavailable — no action was performed` | +| несколько | `Цели недоступны: {names}. Действие не выполнено` | `Targets are unavailable: {names}. No action was performed` | + +`name` берётся из HA/marker friendly name, с fallback на entity/ref. `names` +перечисляет уникальные цели в deterministic intent order через запятую. Строка +рендерится как текст, не HTML. + +`unknown` цели относится к unavailable. `missing` и `ha-disabled` не получают +ложно более точной причины: для человека цель недоступна. Если no-op вызван +`secure`, `unsupported`, отсутствием configured target или отсутствующим +service capability, существующее поведение сохраняется. + +### 7.2. Partial group и гонки + +Если доступна хотя бы одна цель, выполняется ровно существующее доступное +подмножество. Toast «ничего не произошло» не показывается, потому что действие +произошло; skipped targets остаются в текущей подсказке/confirmation. + +При `tap_confirm: true`: + +- полностью недоступная группа показывает toast сразу и не открывает диалог; +- если цель стала недоступна между открытием и подтверждением, повторный resolve + показывает тот же unavailable toast и не вызывает сервис; +- если executable target set изменился иначе, сохраняется текущий + `toast.tap_target_changed`; +- ошибка HA после уже начатого service call остаётся общим `toast.error`. + +Доступность самого контроллера не блокирует dashboard action: если цель +доступна, явный tap по его маркеру продолжает управлять целью. Полупрозрачность +описывает физический контроллер, а tap выполняется интерфейсом House Plan. + +## 8. UX, accessibility и touch + +Нового компонента нет. Используется существующий toast, поэтому focus не +перемещается, модальный слой не создаётся, а сообщение объявляется screen +reader как assertive alert. + +Контракт одинаков для mouse click и короткого touch tap. Long-press, pan, +pinch, drag, pointercancel и kiosk swipe не могут показать toast или отправить +команду. На unavailable/no-op нет 5% press feedback, потому что команда не +отправлена. + +Hosted Static получает правильную нейтральную/полупрозрачную проекцию, но не +новое действие, если поверхность не интерактивна. Preview редактора показывает +тот же live face без toast. + +## 9. Модель данных, compatibility и migration + +Persisted config, `marker.controls`, bindings, registry projection, store/model +version и backend schema не меняются. Миграции нет: существующие маркеры +получают новое поведение при следующем render/state tick. + +Старый frontend продолжит наследовать target availability, новый разделит её; +конфиг остаётся двусторонне читаемым. Controls не переписываются и не +нормализуются этой задачей. + +## 10. Производительность и безопасность + +Own availability вычисляется за `O(e)` по уже построенному `d.entities` одного +маркера и `O(1)` дополнительной памяти. Нельзя повторно строить plan-wide light +graph, обращаться к registry/backend или вводить новый render cache. Оценка +должна использовать тот же HA snapshot, что и presentation. + +Действие остаётся fail-closed: недоступные цели не попадают в service payload. +Friendly names выводятся через Lit text binding; `innerHTML` и доверенный HTML +не используются. Новых сетевых запросов, разрешений или чувствительных данных +нет. + +## 11. Критерии приёмки + +| AC | Требование | Доказательство | +|---|---|---| +| AC1 | Матрица §6.1 даёт точные availability/status/classes для battery/LQI, event-only, own missing, target on/off/unavailable и virtual controller | `test/device-presentation.test.mjs`, основной mutation guard | +| AC2 | Target availability больше не участвует в controller availability, но target `on` по-прежнему даёт working, partial group сохраняет доступную семантику, alarm/live_states/static_icon приоритеты не меняются | presentation unit matrix + existing #107/#174 tests | +| AC3 | Клик по одной полностью недоступной configured target показывает точный локализованный toast с friendly name, без service/WS, confirm, info card и press feedback | pure action unit + production-bundle `smoke_controls` mouse/touch | +| AC4 | Несколько недоступных целей дают plural toast в deterministic order; mixed group вызывает сервис только для доступного подмножества и не показывает no-op toast | toggle unit + browser smoke | +| AC5 | Цель, ставшая unavailable до подтверждения, даёт unavailable toast; иной executable target-set change сохраняет `toast.tap_target_changed`; service rejection сохраняет `toast.error` | confirmation unit/smoke с live state mutation | +| AC6 | View, kiosk, hosted Static и editor preview показывают одинаковое лицо; light/dark и desktop/touch не вводят новый badge, Glow или layout shift | targeted smoke + semantic golden light/dark | +| AC7 | Config round-trip, controls order/content, backend/schema/model versions и virtual-light state не меняются | existing config/device tests + diff review | +| AC8 | Руководства объясняют независимость availability и working, оба changelog ссылаются на #251, i18n RU/EN синхронен | `check-docs`, i18n tests, code review | +| AC9 | Implementation loop зелёный, три поставляемые bundle-копии побайтово одинаковы | `typecheck`, `unit`, `build`, bundle parity | + +## 12. План реализации и автотестов + +1. В `src/device-presentation.ts` добавить pure own-availability projection из + активных `DevItem.entities` и применять её только к controls-derived face, + не меняя resolved light graph и critical sources. +2. Сохранить target aggregate для `working/neutral/activity`, но исключить из + итоговой availability. Явно покрыть virtual, exact entity, device binding, + partial group, live_states и static_icon. +3. В `src/device-toggle.ts` выделить pure классификацию no-op, которому положен + unavailable toast, и deterministic список display names. Не связывать UI с + сырыми внутренними причинами напрямую. +4. В `_clickDevice()` использовать одну ветку уведомления при первичном tap и + при confirm re-resolution. Не запускать feedback до существования operation. +5. Добавить две пары i18n-ключей RU/EN для singular/plural текста и тест parity. +6. Расширить `test/device-presentation.test.mjs`, + `test/device-toggle.test.mjs` и production-bundle smoke controls матрицей AC. +7. Добавить/уточнить semantic golden с живой battery-кнопкой и unavailable + target в light/dark; baseline принимается только в предбетовый цикл. +8. Обновить архитектуру, руководства, testing matrix и changelog RU/EN. + +В implementation loop запускаются только `typecheck`, `unit`, `build`. +Targeted smoke, mutation и docs/process gates выполняются перед code review; +полные golden/smoke/performance — перед бетой по общему процессу. + +## 13. Mutation guards + +| id | Что ломает | Что обязано покраснеть | +|---|---|---| +| `controller-availability-follows-target` | возвращает availability target aggregate вместо own entities | AC1 presentation unit и AC6 semantic golden | +| `controller-diagnostics-do-not-prove-online` | исключает battery/LQI/update из own availability | исходный field fixture AC1 | +| `unavailable-toggle-stays-silent` | восстанавливает ранний quiet return | AC3 action unit и smoke | +| `partial-group-shows-noop-toast` | показывает «ничего не произошло» после частичного service call | AC4 unit/smoke | + +Мутанты не должны подменять production conditions тестовыми issue-флагами. + +## 14. Release-артефакты + +Implementation commit пользовательски видим и получает `User-Visible: yes`. +В том же коммите обязательны: + +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #251; +- `docs/ARCHITECTURE.md` — граница controller availability / target status; +- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md` — поведение controls, + полупрозрачности и no-op toast; +- `docs/TESTING.md` — presentation/action/smoke/golden matrix; +- `docs/STATUS.md` только если unreleased-сводка перечисляет исправления этого + уровня; +- RU/EN i18n, unit/smoke fixtures, mutation manifest и semantic golden guard; +- синхронные production bundles. + +Изменение `src/**` требует актуального screenshot provenance по workflow +`Docs screenshots`. Если существующие снимки не содержат этот state, пиксели +документации не меняются, но проверяемый provenance остаётся обязательным. +Golden baseline принимается только перед бетой из reviewed Linux artifact. + +Новых schema/model migration, backend, security или отдельного performance +артефакта нет. + +## 15. Откат + +Одна code revision возвращает прежнее target-derived availability и тихий +no-op. Данные не меняются, поэтому обратная миграция не нужна. Откат возвращает +ложную полупрозрачность и отсутствие объяснения, но не повреждает конфиг. + +## 16. Риски + +1. **Рабочая цель перестаёт делать контроллер жёлтым.** Митигация: AC1/AC2 + независимо проверяют status и availability. +2. **Event-only контроллер ложно считается online.** Митигация: `unknown` не + является живым состоянием; отдельная fixture. +3. **Диагностика исключена как не-functional entity.** Митигация: own + availability намеренно читает активные entities, а не functional role. +4. **Partial group получает ложное «ничего не произошло».** Митигация: toast + разрешён только при отсутствии operation; AC4 и mutant. +5. **Confirm race остаётся с общим сообщением.** Митигация: повторный intent + классифицируется до generic target-changed branch. +6. **Исправлен View, но Static/preview расходятся.** Митигация: один shared + presentation resolver, AC6. +7. **Toast раскрывает сырую/опасную строку.** Митигация: friendly-name fallback + и Lit text binding без HTML. + +## 17. Принятые предположения — технические, менять свободно + +1. Own availability удобнее представить отдельным pure helper/result, а не + добавлять собственные entities в `visualSources`: последнее смешало бы их + `working` с состоянием цели. +2. Набор `d.entities` уже является активной registry/ownership projection и не + требует повторной registry-фильтрации. +3. Singular/plural выбирается по числу уникальных skipped targets после + resolver de-duplication; порядок берётся из intent. +4. Для UI и тестов предпочтителен общий pure helper no-op classification, + чтобы первичный tap и confirm race не разошлись. +5. Новый semantic golden можно добавить в существующую device-state matrix, + если он явно различает прежний `unavail` и новый neutral state в обеих темах. + +**Не являются предположениями:** собственная доступность по любому живому own +entity state, участие battery/LQI/update, исключение controls из availability, +сохранение target-derived working, отсутствие нового badge и уведомление при +полностью невыполнимом unavailable toggle — решения владельца. diff --git a/docs/specs/README.md b/docs/specs/README.md index 1408eb21..d42ec523 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -127,6 +127,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#242](https://github.com/Matysh/houseplan-card/issues/242) Символ проёма по центру толщины стены | [242-opening-symbol-center.md](242-opening-symbol-center.md) | | [#244](https://github.com/Matysh/houseplan-card/issues/244) Восстановление маркеров с мёртвой ссылкой на пространство | [244-orphan-space-references.md](244-orphan-space-references.md) | | [#248](https://github.com/Matysh/houseplan-card/issues/248) Идемпотентный Optimize после записи и reload | [248-optimize-idempotence.md](248-optimize-idempotence.md) | +| [#251](https://github.com/Matysh/houseplan-card/issues/251) Доступность контроллера не наследуется от управляемой цели | [251-controller-target-availability.md](251-controller-target-availability.md) | ## P3