mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
854 lines
53 KiB
Markdown
854 lines
53 KiB
Markdown
# 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 по нажатию не выполняются.
|
||
|
||
Блокирующих продуктовых вопросов перед реализацией нет.
|