Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
53 KiB
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
Сейчас:
TAP_ACTIONSвsrc/logic.tsобъявляет одновременноtoggleиcover.- Диалог в
src/houseplan-card.tsпоказываетtoggleвсем marker, ноcoverфильтрует по binding. - Save безусловно пишет выбранный
tap_action; backend принимаетtoggleиcover. - При клике
resolveTapAction()повторно проверяет domain и может молча вернутьinfoвместо явно сохранённогоtoggle/cover. _clickDevice()отдельно обрабатывает external controls,run,cover, directtoggle,more-infoи в конце открывает внутреннюю info-card.- Для virtual marker нет
primary, поэтому explicittoggleфактически заканчивается info-card. Пользователь воспринимает это как сброшенную настройку. - Для части пассивных доменов 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. Цели
- Один понятный вариант «Переключить состояние» для всех marker.
- Один resolved result для hint, клика, confirmation и cover-индикации.
- Explicit action всегда сохраняется и никогда не подменяется другой action.
- Никаких service calls при отсутствии безопасной и поддерживаемой цели.
- Сохранить текущую групповую семантику
controls. - Сохранить корректное open/close/stop поведение cover/valve без отдельного пользовательского пункта.
- Обеспечить 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. Список действий
Нормативный порядок:
Карточка устройства(info);Диалог Home Assistant(more-info);Переключить состояние(toggle);Запустить сценарий/автоматизацию(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.
Примеры:
Будет переключаться: Торшер (light.floor_lamp).
Сейчас включено → по нажатию выключится.
Будет переключаться: Шторы (cover.living_room).
Сейчас открываются → по нажатию остановятся.
Будут переключаться 3 источника: Люстра, Бра, Торшер.
Сейчас включён 1 из 3 → по нажатию выключатся все доступные цели.
У этого устройства нет состояния, которое можно переключить.
По нажатию ничего не произойдёт.
Настроенные цели сейчас недоступны. Собственная сущность устройства не будет
подставлена вместо них.
Для одного 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; legacycoverотображается в 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:
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.
Его потребляют:
- hint в диалоге;
_clickDevice();- confirmation text;
- выбор cover entity для presentation/icon morph/activity;
- 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:
- External controls владеют tap только для
explicit-toggle. Если raw persisted controls не пусты, они являются явным пользовательским намерением. - Точный entity binding. Используется только связанная entity. Если она unsupported, missing, disabled или unavailable, resolver не ищет sibling.
- Device binding. Используется первая поддерживаемая entity из
resolvedDeviceStateEntities, то есть из функциональной роли device, а не по registry order. - 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_offservices и обязательные 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:
Переключение замков, сигнализации и защитных ворот с плана заблокировано из
соображений безопасности. По нажатию ничего не произойдёт.
Причины:
- 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 originlegacy-cover, не стирая исходный token; - UI никогда не создаёт новый
cover, однако untouched запись продолжает сериализоваться какcover; - только фактическое изменение action пользователем материализует
toggleи переводит marker на originexplicit-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. Архитектурный план
- Вынести projection action для UI и lossless определение origin
(
explicit-toggle/default-light/legacy-cover) вместе с единымresolveToggleIntentв небольшой pure module, а не наращиватьhouseplan-card.ts. - Использовать shared
resolvedDeviceStateEntitiesи registry lifecycle. - Свести target selection, current command и human explanation в один result.
- Перевести
_clickDevice()на resolved result без fallback. - Перевести cover indicator/presentation на тот же result.
- Перевести dialog hint и confirmation на тот же result.
- Удалить
coverиз UITAP_ACTIONS, сохранив backend compatibility. - Обновить 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;
togglewithout binding target accepted for virtual marker;tap_confirmround-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
coverin 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
Минимум четыре доказательных мутанта:
- Вернуть fallback
infoдля no-target toggle → падает no-op smoke. - Разрешить stale controls fallback на primary → падает controls-intent unit.
- Развести dialog и click resolvers → падает target parity smoke.
- Удалить legacy
coverиз backend schema → падает compatibility test. - Materialize-ить untouched legacy/default при Open → Save → падает lossless round-trip test.
- Считать наличие domain service достаточным без adapter → падает unsupported entity unit.
- Выполнить захваченную до 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. Критерии приёмки
- Вариант называется «Переключить состояние» и виден для любого marker.
- Выбранный toggle сохраняется даже без исполняемой команды.
- Под select всегда есть точный target/semantics hint или точная no-op причина.
- Hint, confirmation, click и cover indication используют один resolver.
- Explicit toggle никогда не открывает info-card/more-info как fallback.
- Virtual без controls — полный no-op; virtual с controls и explicit toggle управляет ими.
- Настроенные controls не дают общего fallback на собственную entity; документированная passive-marker driver semantics остаётся рабочей.
- Cover/valve переключаются корректно, движение останавливается при support.
- Отдельного cover action в UI нет.
- Legacy
tap_action: coverчитается/импортируется и работает без регрессии. - Confirmation сохраняет текущее поведение и config field.
- Passive и unavailable targets не получают ошибочный service call.
- Secure contract реализует принятое решение: lock, alarm и secure cover classes всегда дают no-op без confirmation и service call.
- RU/EN, a11y, touch-view и no-horizontal-scroll требования выполнены.
- Backend validation и import/export round-trip зелёные.
- Untouched legacy cover сохраняет token, старую цель и приоритет даже после Open → Save; явное изменение action переводит его на новый контракт.
- Default-light не отдаёт tap внешним controls без explicit toggle.
- Частично доступная группа вызывает service только для подмножества, показанного в hint; skipped targets видны с причиной.
- Exact entity binding никогда не retarget-ится на sibling, а появление domain service без adapter не делает entity переключаемой.
- Confirmation повторно разрешает intent: изменение state пересчитывает команду, изменение target set отменяет действие.
- Реализация проходит 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. Принятые продуктовые решения
Рекомендуется принять без дополнительных расширений:
-
Один universal UI action, но domain-aware execution.
-
No target — сохранённый no-op, никогда не info fallback.
-
External controls — явное намерение explicit toggle без общего fallback; passive-marker driver остаётся специальным существующим контрактом.
-
Device target выбирается shared functional-role resolver.
-
Legacy cover остаётся compatibility token и сохраняет старое runtime- поведение до явного изменения action пользователем.
-
Cover/valve stop-on-movement сохраняется внутри общего adapter.
-
Target picker не входит в первую версию.
-
Для lock, alarm и cover classes
garage/door/gateпринят secure no-op: action виден и сохраняется, hint объясняет блокировку, service call и confirmation по нажатию не выполняются.
Блокирующих продуктовых вопросов перед реализацией нет.