Files
houseplan-card/docs/specs/094-universal-state-toggle.md

854 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Issue #94 — универсальное действие «Переключить состояние»
- **Статус:** реализовано в `v1.62.0-beta.3`; полный code-review и hardening
capability/live-target contracts включены в кандидат `v1.62.0-beta.4`
- **Issue:** https://github.com/Matysh/houseplan-card/issues/94
- **Область:** frontend, marker action model, HA service dispatch, backend
compatibility, i18n, документация и QA
- **Приоритет:** P2
- **Тип:** bug + feature / UX
## 1. Резюме
В настройках каждого устройства вариант действия по нажатию
`Переключить (свет/розетки)` заменяется универсальным вариантом
**«Переключить состояние»**.
Он показывается для всех устройств без исключения: HA-bound, entity-bound,
device-bound и виртуальных. Выбор всегда сохраняется. Сразу под селектором
диалог объясняет результат тем же resolver, который выполнит действие:
- какую сущность или группу сущностей переключит нажатие;
- какую семантику имеет переключение;
- либо почему у marker сейчас нет переключаемого состояния.
Если цели нет, нажатие является полным no-op: не вызывается HA service, не
открывается карточка устройства и не происходит скрытой подмены действия.
Отдельный пользовательский вариант `Открыть/закрыть (шторы/жалюзи)` удаляется.
Корректная семантика cover/valve остаётся внутренним адаптером общего действия.
Legacy `tap_action: cover` продолжает читаться и импортироваться, но в UI
проецируется как новый `toggle`. До явного изменения действия пользователем
runtime сохраняет исходную cover-семантику и приоритет цели; обычное
Open → Save не является миграцией и не меняет persisted token.
## 2. Исследование текущей реализации
### 2.1. Где расходятся UI, config и runtime
Сейчас:
1. `TAP_ACTIONS` в `src/logic.ts` объявляет одновременно `toggle` и `cover`.
2. Диалог в `src/houseplan-card.ts` показывает `toggle` всем marker, но
`cover` фильтрует по binding.
3. Save безусловно пишет выбранный `tap_action`; backend принимает `toggle` и
`cover`.
4. При клике `resolveTapAction()` повторно проверяет domain и может молча
вернуть `info` вместо явно сохранённого `toggle`/`cover`.
5. `_clickDevice()` отдельно обрабатывает external controls, `run`, `cover`,
direct `toggle`, `more-info` и в конце открывает внутреннюю info-card.
6. Для virtual marker нет `primary`, поэтому explicit `toggle` фактически
заканчивается info-card. Пользователь воспринимает это как сброшенную
настройку.
7. Для части пассивных доменов explicit `toggle`, наоборот, проходит resolver
и вызывает `homeassistant.toggle` у сущности, которая не умеет включаться и
выключаться.
Итоговая ошибка архитектурная: один слой разрешает выбор, второй сохраняет, а
третий самостоятельно решает, считать ли выбор действительным.
### 2.2. Почему нельзя просто снять domain-фильтр
Home Assistant определяет состояние не для device, а для его entities.
`homeassistant.toggle` работает только с entity, которая действительно
поддерживает включение и выключение. Sensor, binary sensor, button, number,
select и многие другие сущности имеют состояние, но не имеют операции toggle.
У составного device могут одновременно присутствовать:
- функциональная сущность (`cover.*`, `climate.*`, `media_player.*`);
- диагностические sensors;
- конфигурационные switches;
- скрытая основная сущность и видимый служебный switch;
- несколько независимо управляемых функций.
Поэтому универсальным должен стать UX и контракт resolver, а не слепой вызов
одного сервиса для любого `primary`.
Официальная модель HA подтверждает это: `homeassistant.toggle` применим только
к entity, поддерживающим turn on/off, а cover и valve предоставляют свои
`toggle`/open/close/stop actions:
- https://www.home-assistant.io/docs/scripts/perform-actions#homeassistant-actions
- https://www.home-assistant.io/integrations/cover
- https://www.home-assistant.io/actions
## 3. Пользовательская ценность
**Оценка: высокая, 8/10.** Это маленький UI-блок, но он исправляет базовое
доверие к настройкам устройства:
- выбранное действие больше не превращается молча в другое;
- до сохранения понятно, чем именно управляет marker;
- виртуальный marker честно сохраняет будущую настройку даже без текущей цели;
- шторы, свет, розетки, climate, media player и другие управляемые domains
укладываются в одну понятную модель;
- пользователь видит ошибочную auto-resolution до первого нажатия;
- support получает entity id и причину no-op прямо из UI, а не восстанавливает
их по конфигу и registry.
Особенно полезно для сложных интеграций, где device содержит десятки entities
и `primary` неочевиден.
## 4. Цели
1. Один понятный вариант «Переключить состояние» для всех marker.
2. Один resolved result для hint, клика, confirmation и cover-индикации.
3. Explicit action всегда сохраняется и никогда не подменяется другой action.
4. Никаких service calls при отсутствии безопасной и поддерживаемой цели.
5. Сохранить текущую групповую семантику `controls`.
6. Сохранить корректное open/close/stop поведение cover/valve без отдельного
пользовательского пункта.
7. Обеспечить lossless compatibility старых конфигов.
## 5. Не входит в задачу
- отдельный пользовательский селектор target entity;
- произвольный выбор HA service и service data;
- настройка разных действий для single/double/hold/right click;
- изменение `Запрашивать подтверждение`;
- изменение long press и right click;
- управление атрибутами, position, brightness, temperature или режимами;
- запуск button/input_button через toggle;
- автоматическая материализация действия у всех старых marker;
- изменение секции `Управляет другими источниками света` и её light semantics;
- полная замена действия `Запустить` для script/scene/automation.
Если auto-target неверен у сложного device, пользователь в первой версии
может создать точный `entity:` binding. Отдельный target picker следует
добавлять только по подтверждённым кейсам, а не расширять этот scope заранее.
## 6. UX диалога
### 6.1. Список действий
Нормативный порядок:
1. `Карточка устройства` (`info`);
2. `Диалог Home Assistant` (`more-info`);
3. `Переключить состояние` (`toggle`);
4. `Запустить сценарий/автоматизацию` (`run`).
`Открыть/закрыть (шторы/жалюзи)` больше не показывается.
`Переключить состояние` видно всегда, независимо от binding и текущей
доступности HA.
### 6.2. Пояснение под селектором
Hint показывается, когда эффективное выбранное действие равно `toggle`, в том
числе когда light использует default toggle без materialized `tap_action`.
Hint связан с select через `aria-describedby` и обновляется при изменении
binding, controls, registry, HA state или action.
Hint обязан показывать не только постоянную пару состояний, но и **текущее
состояние плюс ожидаемый результат следующего нажатия**. Идентификатор target
стабилен, а current/next часть обновляется по live state без Save.
Примеры:
```text
Будет переключаться: Торшер (light.floor_lamp).
Сейчас включено → по нажатию выключится.
```
```text
Будет переключаться: Шторы (cover.living_room).
Сейчас открываются → по нажатию остановятся.
```
```text
Будут переключаться 3 источника: Люстра, Бра, Торшер.
Сейчас включён 1 из 3 → по нажатию выключатся все доступные цели.
```
```text
У этого устройства нет состояния, которое можно переключить.
По нажатию ничего не произойдёт.
```
```text
Настроенные цели сейчас недоступны. Собственная сущность устройства не будет
подставлена вместо них.
```
Для одного target всегда показываются friendly name и entity id. Для группы
показываются количество и имена; полный список entity id доступен в
раскрываемом/переносимом detail либо title, не создающем горизонтальный scroll.
Если часть группы недоступна, hint отдельно называет количество пропущенных
целей и перечисляет их в detail; пользователь до клика видит точный состав
будущего service call.
### 6.3. Confirmation
`Запрашивать подтверждение` остаётся на прежнем месте и с прежней моделью:
- показывается для `toggle`, `run` и legacy cover, спроецированного в toggle;
- сохраняется в `tap_confirm` без изменений;
- confirmation открывается только если resolver вернул исполняемую команду;
- no-op не показывает бессмысленное подтверждение;
- текст confirmation использует те же структурированные target-данные, что hint;
- при открытии confirmation запоминаются идентификаторы целей, но не готовая
команда;
- после подтверждения resolver запускается повторно. Если набор целей изменился,
действие отменяется с toast `Цель действия изменилась. Повторите попытку`;
- если цели те же, но их state изменился, команда пересчитывается по актуальному
состоянию и выполняется. Захваченная до confirmation команда никогда не
исполняется вслепую.
Для сравнения используются отсортированные уникальные entity id текущей
команды; порядок registry/controls не считается изменением цели. Переход
пропущенной цели в доступную или обратно меняет фактический набор команды и
требует нового tap/confirmation.
Если повторный resolver больше не возвращает `command` (entity стала
unavailable/disabled/secure либо исчез service/adapter), действие отменяется с
тем же toast и без вызова HA.
### 6.4. Save
Отображаемое значение select для нетронутого action является живой эффективной
проекцией, а не копией, зафиксированной при открытии диалога. Оно пересчитывается
из `originalTapAction` и актуального `previewDevice.primary` на каждом render:
если HA позднее уточнил ведущую сущность как `light.*`, UI сразу показывает
`Переключить состояние`, как и runtime. После явного изменения select значение
становится пользовательским и больше автоматически не меняется.
- `toggle` разрешено сохранять всегда, даже при `kind: none`.
- Save не требует target.
- После повторного открытия явно выбранный action остаётся `toggle`; legacy
`cover` отображается в select как `toggle`, но сохраняет свой origin.
- Смена binding не сбрасывает action; меняется только resolved hint.
- Если target появляется позднее, сохранённый toggle начинает работать без
повторного Save.
- Dialog хранит `originalTapAction` и `tapActionTouched`. Обычный Open → Save
не materialize-ит default и не переписывает legacy token; новая запись
появляется только после фактического изменения select пользователем.
- Если пользователь действительно изменил select и выбрал
`Переключить состояние`, Save пишет `tap_action: 'toggle'`; с этого момента
применяется новый контракт explicit toggle.
## 7. Единая resolved-модель
Вынести чистый resolver, не завязанный на Lit template:
```ts
type ToggleNoneReason =
| 'no-binding'
| 'no-actionable-entity'
| 'configured-targets-missing'
| 'ha-disabled'
| 'unavailable'
| 'unsupported'
| 'secure';
type ToggleSemantics = 'power' | 'group-power' | 'cover' | 'valve';
type ToggleOrigin =
| 'explicit-toggle'
| 'default-light'
| 'legacy-cover';
type ToggleNextEffect =
| 'turn-on'
| 'turn-off'
| 'open'
| 'close'
| 'stop'
| 'toggle';
interface ResolvedToggleTarget {
entityId: string;
name: string;
state: string | null;
via: 'binding' | 'device-role' | 'control-entity' | 'control-marker-driver';
}
interface SkippedToggleTarget {
ref: string;
entityId: string | null;
name: string | null;
reason: 'missing' | 'ha-disabled' | 'unavailable' | 'unsupported' | 'secure';
}
interface ResolvedToggleIntent {
origin: ToggleOrigin;
kind: 'single' | 'group' | 'none';
semantics: ToggleSemantics | null;
// Только цели текущего service call; порядок детерминирован.
targets: ResolvedToggleTarget[];
skippedTargets: SkippedToggleTarget[];
noneReason: ToggleNoneReason | null;
nextEffect: ToggleNextEffect | null;
command: null | {
domain: string;
service: string;
data: { entity_id: string | string[] };
};
}
```
Название интерфейса не нормативно; нормативна одна функция и один результат.
`command !== null` является единственным признаком исполнимости; отдельное
поле `executable` не вводится. Pure resolver не возвращает готовые
локализованные `label`/`detail`: общий formatter строит hint, confirmation и
диагностику из этого же результата. Это исключает расхождение перевода и
фактической команды.
Для частичной группы `targets` в точности соответствует текущему service call,
а `skippedTargets` сохраняет пользовательское намерение и причину пропуска.
Для single target, который временно недоступен, `targets` пуст, identity
показывается из `skippedTargets`, `command` равен `null`.
Resolver получает:
- effective action;
- marker/config и `DevItem`;
- registry-aware HA projection;
- resolved devices для marker controls;
- текущие states/services.
Его потребляют:
1. hint в диалоге;
2. `_clickDevice()`;
3. confirmation text;
4. выбор cover entity для presentation/icon morph/activity;
5. preview diagnostics при необходимости.
Ни один consumer не повторяет domain/target resolution самостоятельно.
## 8. Нормативный алгоритм выбора цели
### 8.1. Приоритет намерения
Сначала определяется origin действия:
- `explicit-toggle` — пользователь явно сохранил `tap_action: 'toggle'`;
- `default-light` — у light отсутствует materialized action и действует
существующий default;
- `legacy-cover` — в конфиге сохранён `tap_action: 'cover'`.
Далее цель выбирается с учётом origin:
1. **External controls** владеют tap только для `explicit-toggle`. Если raw
persisted controls не пусты, они являются явным пользовательским намерением.
2. **Точный entity binding.** Используется только связанная entity. Если она
unsupported, missing, disabled или unavailable, resolver не ищет sibling.
3. **Device binding.** Используется первая поддерживаемая entity из
`resolvedDeviceStateEntities`, то есть из функциональной роли device, а не
по registry order.
4. **Virtual без controls.** `kind: none`, `no-actionable-entity`.
`default-light` сохраняет прежнее поведение: переключает собственную light и
не передаёт tap внешним controls. `legacy-cover` сохраняет прежнюю cover-цель
и игнорирует controls до явного изменения action пользователем. Благодаря
этому открытие старого marker в новом UI не является скрытым изменением
поведения.
Нельзя переходить к следующему пункту, если более приоритетное *явное*
намерение существует, но временно недоступно. В частности, stale/disabled
controls не дают общего fallback на собственный switch контроллера; единственное
исключение — явно описанная ниже driver-семантика ссылки на passive forced-light
marker.
### 8.2. Фильтрация device candidates
Для device binding:
- config/diagnostic entities не становятся implicit target;
- disabled registry rows не участвуют;
- hidden functional entity участвует: hidden в HA не означает disabled;
- unavailable/missing entity сохраняется как объяснимый target, но команда не
выполняется до восстановления;
- функциональная роль (`cover`, `climate`, `media_player`, `light`, etc.)
приоритетнее служебных switches;
- если resolved role пассивна или заблокирована secure contract, случайный
sibling switch не подставляется;
- exact `entity:` binding может осознанно выбрать такой switch.
Это предотвращает повторение проблем с Anti interference, Customized Cleaning,
reverse direction и другими peer/config entities.
### 8.3. External controls
Сохраняется текущий контракт:
- resolved entity/marker references de-duplicate;
- обычная ссылка на entity разрешается только в эту entity, без fallback;
- ссылка на пассивный forced-light marker разрешается в собственную
переключаемую entity контроллера как в driver этой связи. Это специальная
семантика существующей модели «умный выключатель управляет виртуальной тупой
лампой», а не общий fallback при stale control; если у контроллера нет
собственной поддерживаемой actionable entity, связь остаётся no-op;
- несколько пассивных marker, использующих один driver, дают одну
дедуплицированную service target;
- если хотя бы одна активная цель `on`, команда — `homeassistant.turn_off` для
всей группы;
- если все активные цели `off`, команда — `homeassistant.turn_on`;
- временно missing/disabled цели не удаляются из config;
- service call получает только доступное и поддерживаемое подмножество. Hint
показывает число и список пропущенных целей; group state считается только
по целям, которые войдут в вызов;
- если после resolution нет ни одной доступной поддерживаемой цели, result =
`configured-targets-missing`, без fallback;
- self-reference не становится external control;
- виртуальный marker с валидными controls переключает их точно так же, как
HA-bound controller.
Эта задача не расширяет допустимый состав `controls`: он остаётся частью
существующей light-source модели и её текущей валидации. Универсальность нового
tap action относится к выбору собственной цели marker, а не превращает
`Управляет другими источниками света` в произвольный multi-domain target picker.
## 9. Domain adapters
### 9.1. Power/toggle
Entity допускается, если:
- binding активен;
- entity не относится к secure contract;
- для domain существует централизованный adapter с известным контрактом
turn_on/turn_off/toggle, а entity удовлетворяет его требованиям;
- нужный service присутствует в текущем `hass.services` как runtime guard;
- entity существует в registry/state projection.
Само наличие `<domain>.toggle` в `hass.services` не доказывает capability
конкретной entity: список services публикуется на уровне domain. Поэтому нельзя
строить поддержку только на этой проверке или слепо вызывать
`homeassistant.toggle` для произвольного state.
Каждый adapter обязан декларативно задать:
- predicate применимости к конкретной entity, включая `supported_features`,
если domain кодирует capability feature bits;
- какие states считаются off и on/active;
- точные `turn_on`/`turn_off` services и обязательные service data;
- допустим ли `toggle` при `unknown`, либо безопасен только no-op;
- человекочитаемые current/next semantics для formatter.
При известном state resolver предпочтительно строит направленную команду
`turn_on` или `turn_off`: именно её обещает hint. `<domain>.toggle` допустим
только если adapter явно объявляет его безопасным для неопределённого state.
Если adapter не может доказать capability или next effect, entity получает
`unsupported`, а не оптимистичный service call.
Registry может содержать отдельные проверенные adapters для light, switch,
fan, humidifier, climate, media_player, input_boolean, automation, remote,
siren, vacuum, water_heater и других domains. Появление нового service в HA
само по себе не включает domain автоматически. `script`/`scene` не получают
toggle только из-за наличия `turn_on`: их запуск остаётся action `run`.
Список adapters и state semantics не копируется по renderer/click paths.
Реализация использует один декларативный `POWER_ADAPTERS`. Для доменов, где
Home Assistant регистрирует service на весь domain, но ограничивает конкретную
entity через `supported_features`, adapter требует точные bits: climate
`TURN_OFF/TURN_ON`, water heater `ON_OFF`, siren `TURN_OFF/TURN_ON`, camera
`ON_OFF`, media player `TURN_OFF/TURN_ON` и legacy vacuum `TURN_OFF/TURN_ON`.
Отсутствующий или пустой `hass.services` не считается оптимистическим
разрешением.
Для automation hint обязан говорить «включить/выключить автоматизацию», а не
«запустить»: запуск остаётся отдельным action `run`.
### 9.2. Cover
Пользователь видит общий `toggle`, но resolver использует cover semantics:
- `closed` → `cover.open_cover` либо `cover.toggle`;
- `open`/ajar → `cover.close_cover` либо `cover.toggle`;
- `opening`/`closing` + feature support и наличие service stop →
`cover.stop_cover`;
- движение без stop support → `cover.toggle`;
- `unknown` → `cover.toggle`;
- `unavailable`/missing → no-op до восстановления.
Для каждой ветки проверяются feature support конкретной entity и наличие
выбранного service. Fallback на `cover.toggle` разрешён только если adapter
подтвердил его доступность; иначе результат — `unsupported`/no-op.
Таким образом удаляется отдельная настройка, но не ухудшается уже корректное
поведение «нажатие во время движения останавливает штору».
### 9.3. Valve
Аналогично cover:
- closed → open;
- open → close;
- opening/closing + feature support и наличие service stop → stop;
- иначе `valve.toggle`;
- unavailable/missing → no-op.
Для open/close/stop/toggle действуют те же двойные guards: feature capability
конкретной entity и наличие service в загруженном domain.
### 9.4. Непереключаемые domains
Sensor, binary_sensor, number, select, text, event, button, input_button,
script, scene, image, weather, person, device_tracker и прочие entities без toggle
service дают `no-actionable-entity`/`unsupported`. Наличие изменяющегося state
само по себе не считается capability.
## 10. Защищённые устройства — принятое решение
Существующий House Plan contract запрещает обычным tap переключать:
- `lock.*`;
- `alarm_control_panel.*`;
- secure cover classes `garage`, `door`, `gate`.
Запрет сохраняется. Вариант `Переключить состояние` остаётся видим
и сохраняется, но resolver возвращает `kind: none`, `reason: secure` и hint:
```text
Переключение замков, сигнализации и защитных ворот с плана заблокировано из
соображений безопасности. По нажатию ничего не произойдёт.
```
Причины:
- optional confirmation не является достаточной защитой: пользователь может
оставить её выключенной;
- generic toggle не выражает arm mode, code, lock/unlock direction;
- HA и voice ecosystems отдельно классифицируют lock, alarm и door/garage/gate
covers как secure devices;
- расширять полномочия существующих конфигов молча нельзя.
Любое будущее разрешение secure toggle является отдельной задачей с
обязательным confirmation и явным предупреждением. Текущая задача такого
расширения не авторизует.
Secure-фильтр применяется после разрешения каждой цели, включая targets,
полученные через binding, device role и external controls. Косвенная ссылка на
secure entity не позволяет обойти запрет. Если группа по ошибке содержит такую
цель, она исключается из команды и явно отмечается в hint; если безопасных целей
не осталось, результат — `secure`/no-op.
## 11. Legacy и модель данных
### 11.1. Persisted tokens
Новых config fields не требуется:
- новый UI пишет `tap_action: 'toggle'`;
- backend продолжает принимать `tap_action: 'cover'` как legacy read/import
compatibility token;
- UI показывает legacy `cover` как выбранный пункт `toggle`, но runtime
передаёт resolver origin `legacy-cover`, не стирая исходный token;
- UI никогда не создаёт новый `cover`, однако untouched запись продолжает
сериализоваться как `cover`;
- только фактическое изменение action пользователем материализует `toggle` и
переводит marker на origin `explicit-toggle`;
- импорт/полный round-trip старого плана не падает до пользовательского
редактирования.
Backend не должен начать отклонять untouched legacy `cover`, иначе любая
несвязанная запись всей конфигурации может сломаться.
### 11.2. Default light action
Текущий default сохраняется:
- pure `light` без explicit action по умолчанию переключается;
- остальные marker по умолчанию открывают info-card;
- dialog hint для default light строится как для toggle;
- простое Open → Save не обязано materialize default, если пользователь не
менял action;
- наличие `controls` не меняет default-light tap: внешняя группа получает
приоритет только после явного выбора `Переключить состояние`;
- пользовательский выбор другого action остаётся сильнее default.
### 11.3. Presentation
Если resolved toggle target имеет semantics `cover`, он становится тем же
cover indicator, который управляет morph/activity marker. Отдельная проверка
`tapAction === 'cover'` удаляется. Mixed device показывает cover только если
именно cover выбран единым target resolver; иначе presentation не меняется.
## 12. Lifecycle и edge cases
| Ситуация | Нормативное поведение |
|---|---|
| Virtual без controls | Сохраняет toggle; no-target hint; tap = no-op |
| Virtual с active controls и explicit toggle | Group hint и group toggle |
| Virtual controls временно missing | Missing hint; no fallback; config сохранён |
| Control ссылается на passive forced-light marker | Собственная actionable entity контроллера становится driver связи; без неё no-op |
| Entity-bound light/switch | Точная entity, без sibling inference |
| Entity-bound sensor | Unsupported hint; tap = no-op |
| Device с cover + config switch | Cover role, config switch не перехватывает tap |
| Device с climate + sensors | Climate role, sensors не участвуют |
| Switch-only composite device | Выбранный resolved Power/representative switch показывается в hint |
| Несколько равноправных functional entities | Детерминированно первая по shared role resolver; hint честно показывает её |
| Target state `unknown` | Target сохраняется; adapter может вызвать domain toggle |
| Target `unavailable` | Hint «сейчас недоступно»; tap = no-op |
| Entity исчезла из states, но есть registry row | Missing/unavailable hint; без retarget |
| Entity disabled_by HA | Команды нет; lifecycle устройства остаётся действующим |
| Весь device disabled | Marker скрыт по действующему contract; action/config не стираются |
| Friendly name изменён | Hint обновляет имя, entity id остаётся тем же |
| Integration добавила более сильную role entity | Auto-target device binding может измениться; hint отражает результат |
| Пока #73 показывает последний цельный visual frame | Click повторно находит текущий marker по id и разрешает action/controls только из live devices; исчезнувший marker даёт no-op |
| Explicit controls + own relay | Controls полностью выигрывают; relay не fallback |
| Default light + controls | Собственный light default сохраняется; controls не перехватывают tap |
| Legacy cover + controls | До явного изменения action сохраняется старая cover-цель; controls игнорируются |
| Legacy device содержит disabled и active cover | Active cover имеет приоритет; disabled cover используется только как историческая объяснимая цель, если активной больше нет |
| Group: one on, others off | Turn off all |
| Group: all off | Turn on all |
| Group: часть unavailable/missing | Команда для доступного подмножества; hint перечисляет пропущенные цели |
| Cover moving | Stop при support, иначе domain toggle |
| Legacy `cover`, Open → Save | UI показывает toggle; runtime сохраняет legacy cover semantics; token остаётся `cover` |
| Legacy `cover`, пользователь меняет action на toggle | Save пишет `toggle`; начинает действовать explicit-toggle contract |
| `tap_confirm: true`, target есть | Confirmation перед service call |
| `tap_confirm: true`, target нет | Confirmation не показывается, tap = no-op |
| Target state изменился в confirmation | При тех же target id команда пересчитывается и выполняется по актуальному state |
| Target set изменился в confirmation | Действие отменяется с toast; stale command не выполняется |
| static_icon | Меняет только presentation, action работает как настроено |
| hidden marker | Не интерактивен как и сейчас |
| right click / long press | Без изменений: info/more-info path |
| Service исчез между hint и tap | Повторный resolution; no-op + toast, без stale call |
| Service call rejected | Локализованный error toast, action/config не меняются |
## 13. Ошибки и feedback во время View
В обычном стабильном no-target состоянии клик ничего не показывает: hint уже
объяснил настройку, а marker может быть виртуальной декоративной кнопкой.
Если исполняемая команда существовала, но исчезла между render и click, допускается
один короткий toast `Действие сейчас недоступно`; service call не выполняется.
Ошибка реального service call использует существующий error toast.
Нельзя открывать info-card как fallback ни в одном explicit toggle case.
## 14. Accessibility и touch
- select имеет постоянный label и `aria-describedby` на hint;
- изменение hint объявляется через ненавязчивый `aria-live="polite"` только
при пользовательском изменении action/binding, не на каждом HA state tick;
- entity id остаётся копируемым текстом;
- длинные имена переносятся и не создают horizontal scroll;
- no-target и secure состояния различимы не только цветом;
- View tap target остаётся marker; новая подпись не появляется на плане и не
вмешивается в pinch/pan;
- editor на touch остаётся best-effort по общей политике, View — полностью
поддерживаемым.
## 15. I18n
Добавить/обновить синхронно RU/EN:
- `tap.toggle`: «Переключить состояние» / “Toggle state”;
- single target hint;
- group target hint и group semantics;
- no actionable state;
- configured targets missing;
- unavailable target;
- secure target blocked;
- partially unavailable/skipped targets;
- target changed during confirmation;
- cover/valve semantics;
- current action wording при необходимости;
- accessible descriptions.
Удалить из активного UI, но не обязательно из legacy словарей, `tap.cover`.
Тест паритета i18n обязателен.
## 16. Архитектурный план
1. Вынести projection action для UI и lossless определение origin
(`explicit-toggle`/`default-light`/`legacy-cover`) вместе с единым
`resolveToggleIntent` в небольшой pure module, а не наращивать
`houseplan-card.ts`.
2. Использовать shared `resolvedDeviceStateEntities` и registry lifecycle.
3. Свести target selection, current command и human explanation в один result.
4. Перевести `_clickDevice()` на resolved result без fallback.
5. Перевести cover indicator/presentation на тот же result.
6. Перевести dialog hint и confirmation на тот же result.
7. Удалить `cover` из UI `TAP_ACTIONS`, сохранив backend compatibility.
8. Обновить marker types/comments/import-export policy.
## 17. Тестовая матрица
### 17.1. Unit
- action origin/projection: every current token + legacy cover + unknown;
- untouched legacy cover сохраняет token, target priority и presentation;
- touched legacy action materialize-ит explicit toggle;
- default light с controls не меняет прежний собственный target;
- exact entity light/switch/fan/climate/media player/vacuum, когда
зарегистрированный adapter подтверждает capability;
- sensor/binary_sensor/button/select/number/scene no-op;
- service absent from `hass.services` → unsupported;
- наличие domain service без зарегистрированного entity adapter не делает
entity переключаемой;
- cover closed/open/opening/closing/unknown/unavailable, with/without stop;
- cover/valve stop требует и feature bit, и доступный service;
- valve equivalent;
- lock/alarm/secure cover → secure no-op;
- virtual without controls;
- virtual with entity and marker controls;
- passive forced-light marker control resolves to controller driver and
de-duplicates repeated driver targets;
- controls any-on/all-off group semantics;
- partially unavailable group executes available subset and reports skipped;
- configured but all missing controls do not fall back;
- exact entity binding outranks device siblings;
- device role outranks config/diagnostic switches;
- hidden functional entity remains eligible, disabled one does not;
- legacy cover сохраняет прежнюю cover target/presentation semantics, пока
пользователь явно не изменил action;
- default light and explicit override;
- exact entity unsupported/missing never retargets to device sibling;
- confirmation re-resolve: same targets/new state recomputes command; changed
targets cancel without a call;
- no target never resolves to info/more-info.
### 17.2. Backend/import-export
- every current UI action accepted;
- legacy cover accepted;
- unknown action rejected;
- `toggle` without binding target accepted for virtual marker;
- `tap_confirm` round-trip for command and no-target cases;
- full/partial import/export preserves legacy and new records;
- unrelated config save cannot fail because an untouched marker contains
`cover`.
### 17.3. Browser/smoke
- selector shows `Переключить состояние` for HA device, exact entity and
virtual marker;
- `Открыть/закрыть` absent;
- virtual select → Save → reopen remains toggle;
- legacy cover Open → Save remains `cover` in persisted config;
- explicit change of legacy cover writes `toggle`;
- virtual no-target click does not open info-card and makes no service call;
- hint target exactly equals service-call target;
- binding and controls changes update hint before Save;
- unavailable transition changes hint and suppresses call;
- когда вся группа доступна, group call uses the complete resolved list;
- partial group call uses exactly the available subset shown in hint;
- cover tap chooses open/close/stop correctly;
- confirmation unchanged for direct/group/cover and absent for no-op;
- RU/EN long hints fit without horizontal scroll;
- right click/long press unchanged.
### 17.4. Mutation gate #85
Минимум четыре доказательных мутанта:
1. Вернуть fallback `info` для no-target toggle → падает no-op smoke.
2. Разрешить stale controls fallback на primary → падает controls-intent unit.
3. Развести dialog и click resolvers → падает target parity smoke.
4. Удалить legacy `cover` из backend schema → падает compatibility test.
5. Materialize-ить untouched legacy/default при Open → Save → падает lossless
round-trip test.
6. Считать наличие domain service достаточным без adapter → падает unsupported
entity unit.
7. Выполнить захваченную до confirmation команду после смены target set →
падает confirmation race smoke.
## 18. Документация
Обновить при реализации:
- `docs/USER-GUIDE.ru.md` и английскую пользовательскую документацию;
- `docs/ARCHITECTURE.md` — единый device action resolver;
- `docs/FILTERING.md` — что считается functional action entity;
- `docs/TESTING.md`;
- config examples;
- RU/EN changelog beta.
Старые тексты о `TOGGLE_SAFE_DOMAINS`, отдельном `tap_action: cover` и
безусловном `_actEntity = primary` должны быть удалены или помечены legacy.
## 19. Критерии приёмки
1. Вариант называется «Переключить состояние» и виден для любого marker.
2. Выбранный toggle сохраняется даже без исполняемой команды.
3. Под select всегда есть точный target/semantics hint или точная no-op причина.
4. Hint, confirmation, click и cover indication используют один resolver.
5. Explicit toggle никогда не открывает info-card/more-info как fallback.
6. Virtual без controls — полный no-op; virtual с controls и explicit toggle
управляет ими.
7. Настроенные controls не дают общего fallback на собственную entity;
документированная passive-marker driver semantics остаётся рабочей.
8. Cover/valve переключаются корректно, движение останавливается при support.
9. Отдельного cover action в UI нет.
10. Legacy `tap_action: cover` читается/импортируется и работает без регрессии.
11. Confirmation сохраняет текущее поведение и config field.
12. Passive и unavailable targets не получают ошибочный service call.
13. Secure contract реализует принятое решение: lock, alarm и secure cover
classes всегда дают no-op без confirmation и service call.
14. RU/EN, a11y, touch-view и no-horizontal-scroll требования выполнены.
15. Backend validation и import/export round-trip зелёные.
16. Untouched legacy cover сохраняет token, старую цель и приоритет даже после
Open → Save; явное изменение action переводит его на новый контракт.
17. Default-light не отдаёт tap внешним controls без explicit toggle.
18. Частично доступная группа вызывает service только для подмножества,
показанного в hint; skipped targets видны с причиной.
19. Exact entity binding никогда не retarget-ится на sibling, а появление
domain service без adapter не делает entity переключаемой.
20. Confirmation повторно разрешает intent: изменение state пересчитывает
команду, изменение target set отменяет действие.
21. Реализация проходит beta до stable по promotion rule.
## 20. Сложность, риски и оценка
### 20.1. Сложность
**Средняя, ближе к высокой.** Переименование и hint дёшевы; основная работа —
сделать один resolver вместо нескольких несовпадающих решений.
| Блок | Оценка |
|---|---:|
| Pure resolver, adapters, legacy origin/projection | 1,25–2 дня |
| Dialog hint, i18n, a11y | 0,5–1 день |
| Click/confirmation/presentation integration | 0,5–1 день |
| Backend/import-export compatibility | 0,25–0,5 дня |
| Unit/browser/mutation/docs/beta hardening | 1–1,5 дня |
| **Итого** | **3,5–6 рабочих дней, одна beta-итерация** |
### 20.2. Риски
| Риск | Вероятность / ущерб | Снижение |
|---|---|---|
| Не та entity у composite device | средняя / высокий | shared role resolver + visible entity id |
| Stale controls неожиданно переключат controller | высокая / высокий | no fallback after explicit intent |
| Регрессия штор при удалении cover option | средняя / высокий | internal cover adapter + legacy matrix |
| Случайное unlock/disarm/open gate | низкая / критический | secure no-op contract |
| Domain публикует service, но entity его не поддерживает | средняя / средний | domain adapter + call error handling + fixtures |
| Legacy cover ломает config write или меняет цель при Open → Save | средняя / высокий | origin + touched gate + lossless round-trip |
| Hint и клик расходятся после live update | средняя / высокий | один resolver; re-resolve at click |
| Слишком длинный hint | высокая / низкий | wrap/detail, no horizontal scroll |
| Scope разрастается в target picker | средняя / средний | entity binding как точный escape hatch |
## 21. Принятые продуктовые решения
Рекомендуется принять без дополнительных расширений:
1. Один universal UI action, но domain-aware execution.
2. No target — сохранённый no-op, никогда не info fallback.
3. External controls — явное намерение explicit toggle без общего fallback;
passive-marker driver остаётся специальным существующим контрактом.
4. Device target выбирается shared functional-role resolver.
5. Legacy cover остаётся compatibility token и сохраняет старое runtime-
поведение до явного изменения action пользователем.
6. Cover/valve stop-on-movement сохраняется внутри общего adapter.
7. Target picker не входит в первую версию.
8. Для lock, alarm и cover classes `garage`/`door`/`gate` принят secure no-op:
action виден и сохраняется, hint объясняет блокировку, service call и
confirmation по нажатию не выполняются.
Блокирующих продуктовых вопросов перед реализацией нет.