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

53 KiB
Raw Permalink Blame History

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:

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.

Примеры:

Будет переключаться: Торшер (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; 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:

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:

Переключение замков, сигнализации и защитных ворот с плана заблокировано из
соображений безопасности. По нажатию ничего не произойдёт.

Причины:

  • 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 по нажатию не выполняются.

Блокирующих продуктовых вопросов перед реализацией нет.