# Issue #178 — выбор сущности для действия «Переключить состояние» - **Issue:** https://github.com/Matysh/houseplan-card/issues/178 - **Редакция:** первая редакция для независимого ревью; статус определяется только метками issue - **Тип / приоритет:** feature / P1 - **Оценка:** пользовательская ценность 6/10; ценность для разработки 3/10; сложность/риск 4/10 - **Область:** диалог устройства, marker config, Toggle resolver, групповые действия, import/export, i18n и тесты - **Новое поле:** `marker.toggle_entity?: string | null` - **Связано:** #50, #84/#88, #94, #103, #107, #174, `docs/SCOPE.md`, `docs/CONFIG-COMPATIBILITY.md` ## 1. Сценарий и цель **Персона:** администратор Home Assistant, разместивший на плане составное устройство: стиральную машину, многоканальное реле, розетку с отдельной блокировкой либо другой прибор с несколькими `light.*`/`switch.*`. **Момент:** для marker выбрано действие **«Переключить состояние»**, но текущая эвристика выбрала не ту собственную сущность. Пользователь видит вычисленную цель в подсказке, однако не может заменить питание детской блокировкой, каналом реле или наоборот. Задача поддерживает J3 из `docs/SCOPE.md`: действие с плана должно иметь очевидную и управляемую пользователем точную цель. ## 2. Что изменится для пользователя **До:** House Plan сам выбирает собственную цель Toggle через binding, functional role и primary entity. Для составного устройства эту цель нельзя исправить в UI. **После:** когда эффективное действие marker — «Переключить состояние» и у устройства есть не меньше двух собственных управляемых сущностей, под действием показывается селектор **«Переключаемая сущность»**. Выбор немедленно меняет подсказку цели и после сохранения определяет точную собственную сущность Toggle. Устройство с одной управляемой сущностью и любой старый config без нового поля выглядят и работают как раньше. ## 3. Подтверждённое текущее состояние 1. `resolveToggleIntent()` передаёт обычный single Toggle в `resolveOwnEntity()`; `ownRoleCandidates()` строит детерминированную цепочку из binding, `light_entity`, primary и выбранной functional role. 2. `light_entity` сейчас намеренно участвует в legacy action resolution: так marker не светится от одной лампы и молча не переключает другое реле. 3. Диалог строит runtime marker через `_markerDraft()` и production pipeline, поэтому `toggle_hint_*` уже может показывать draft-цель до Save. 4. `ownControllableEntities()` даёт совместимый список собственных `light.*`/`switch.*` в порядке binding → primary → остальные кандидаты. 5. При непустом `controls` explicit Toggle сейчас формирует группу только из внешних целей и не вызывает `resolveOwnEntity()`. 6. Паттерн #88 уже хранит `light_entity`, показывает предупреждение при stale выборе и временно возвращается к совместимому fallback без стирания поля. 7. Backend допускает lossless marker fields через схему, но для нового entity-id требуется отдельная delta-validation, как для `light_entity`. 8. При space import с политикой `virtual` HA-зависимые поля marker удаляются явным allowlist; новое поле необходимо добавить в этот путь. ## 4. Решения владельца Нормативны решения из описания issue от 18.08.2026: https://github.com/Matysh/houseplan-card/issues/178 1. Рабочее и каноническое имя поля — `toggle_entity`. 2. Селектор показывается только при эффективном Toggle и осмысленном выборе. 3. Отсутствие поля сохраняет текущую цепочку бит-в-бит; миграции нет. 4. Stale выбор сохраняется, показывает предупреждение и временно использует прежнюю цепочку. 5. Подсказка цели реагирует немедленно, до сохранения. 6. HA entity id переносится буквально. 7. `tap_target`, `light_entity` и `toggle_entity` — независимые поля. 8. При внешних `controls` явно выбранная собственная сущность участвует в группе, но правило `any-on → turn_off all` не меняется. 9. Виртуальные marker и операционные цели `marker:*` не получают собственного выбора в рамках #178. ## 5. Scope В #178 входят: 1. новое optional marker field `toggle_entity`; 2. список собственных активных `light.*`/`switch.*` кандидатов; 3. селектор и stale-warning в диалоге устройства; 4. live-preview выбранной цели через существующие `toggle_hint_*`; 5. explicit single resolution выбранной сущности; 6. добавление выбранной собственной сущности в explicit controls-group; 7. точное сохранение legacy resolution при отсутствии поля; 8. lossless persistence, delta-validation и transfer policy; 9. RU/EN локализация, пользовательская и compatibility-документация; 10. unit, browser smoke, golden и release-артефакты. ## 6. Non-scope В задачу не входят: - выбор сущностей доменов, отличных от `light.*` и `switch.*`; - изменение списка либо порядка внешних `controls`; - выбор `marker:*`, virtual-light operation или incoming controller; - изменение действия «Запуск» и поля `tap_target`; - объединение `toggle_entity` с `light_entity`; - изменение определения functional role, primary entity или appliance lifecycle из #164; - изменение confirmation flow #103; - новые HA services, permissions, зависимости или миграция старых config; - автоматическое удаление stale значения; - изменение card-level legacy `tap_action`. ## 7. Модель данных и compatibility ### 7.1 Поле marker ```ts interface Marker { /** Exact own light/switch selected for Toggle. Absence keeps legacy resolution. */ toggle_entity?: string | null; } ``` Значение — полный HA entity id `light.` либо `switch.`. Пустая строка из UI не сохраняется как entity id: выбор «Автоматически» материализуется как `null` только после явного касания поля; нетронутое отсутствие остаётся отсутствием. В runtime-форме `DevItem.marker` доступно это же поле. Оно не копируется в `controls` и не становится `primary`. ### 7.2 Совместимость без миграции Если own property `toggle_entity` отсутствует либо равен `null`, resolver идёт по существующей цепочке без нового шага. Это включает: - exact entity binding; - текущее влияние валидного `light_entity`; - device functional role и primary fallback; - существующий skip/fail-closed порядок missing, unavailable, secure, HA-disabled и capability-unsupported целей; - external-only controls group. Тем самым open → save без касания селектора не материализует поле и не меняет ни цель, ни группу. ### 7.3 Stale и невалидные значения **Stale** — непустой сохранённый `toggle_entity`, которого нет среди текущих собственных активных controllable candidates marker. Сюда относится удалённая, переименованная, перенесённая на другое HA-устройство либо disabled-by-registry сущность. - UI сохраняет literal и показывает warning. - Runtime не вызывает service по stale id, а временно использует §7.2. - Возврат сущности в candidate set автоматически восстанавливает выбор. - Save другого поля не стирает stale literal. - Новый либо изменённый value, не совпадающий с `^(light|switch)\.[a-z0-9_]+$`, backend отклоняет. - Неизменённый неизвестный/future literal старого config разрешено round-trip-ить по lossless doctrine; полный import валидирует всё входное. ### 7.4 Export/import - Full export/import и space transfer копируют `toggle_entity` буквально. - Entity id не remap-ится между HA instances и не превращается в warning preview только из-за отсутствия в текущем snapshot. - При duplicate policy `virtual`, когда HA marker превращается в virtual, `toggle_entity` удаляется вместе с `tap_action`, `light_entity`, `controls` и другими HA-зависимыми полями. - Plan-only export из #167 не получает отдельной семантики: поле следует за marker согласно существующему contract выбранного export mode. ## 8. Кандидаты и effective selection ### 8.1 Candidate set Единый pure helper возвращает собственные **активные** сущности marker: 1. exact entity binding, если это `light.*`/`switch.*`; 2. current primary, если это `light.*`/`switch.*`; 3. остальные активные registry entities того же HA device в существующем детерминированном порядке; 4. дубликаты удаляются с сохранением первого вхождения. Hidden entity допускается, если она активна и является собственной: пользователь может осознанно переключать скрытый channel. Registry-disabled и чужие sibling entities не являются кандидатами. Transient state `unknown`, `unavailable` или отсутствующий state object не удаляет registry-active candidate: capability не должна зависеть от текущего состояния. Для device binding берутся собственные entities устройства. Entity binding уже является точным пользовательским выбором, поэтому его candidate set содержит только exact binding и не расширяется registry siblings. У virtual binding candidate set пуст. ### 8.2 Effective own entity - Валидный и присутствующий `toggle_entity` становится первым и точным own target; resolver не перешагивает с него на sibling из-за временного unavailable/missing/secure state. - Stale/invalid explicit value не подаётся в service resolver; используется полный legacy fallback §7.2. - При отсутствии explicit value используется только legacy fallback. - `light_entity` продолжает влиять на legacy fallback, но никогда не заменяет валидный explicit `toggle_entity`. ## 9. Диалог устройства ### 9.1 Видимость и расположение Селектор расположен сразу под `marker-tap-action` и до текущей Toggle-подсказки. Он виден, когда одновременно: 1. effective action draft равен `toggle`; 2. candidate set содержит минимум две сущности **либо** сохранённый непустой `toggle_entity` stale. Stale исключение обязательно: иначе пользователь не увидит warning и не сможет исправить сохранённый выбор после исчезновения сущности. При одной кандидатке и без stale селектора нет. При смене action на другое значение selector исчезает, но нетронутый literal не стирается до Save и не используется runtime. ### 9.2 Содержимое - Label: «Переключаемая сущность» / “Entity to toggle”. - Первая option: «Автоматически: {effective legacy target}»; при отсутствии цели — локализованное «нет доступной сущности». - Каждая candidate option содержит friendly name и entity id по тому же доступному native-select паттерну, что `light_entity`: `Name · entity.id`. - Explicit stale value показывается через выбранную Auto/fallback option и отдельный warning с stale id и effective fallback id. - Help поясняет независимость от источника света и действие Auto. Новый select остаётся native `` с обычным tap, связанным `