diff --git a/docs/specs/178-toggle-entity.md b/docs/specs/178-toggle-entity.md new file mode 100644 index 00000000..2a3bc92c --- /dev/null +++ b/docs/specs/178-toggle-entity.md @@ -0,0 +1,437 @@ +# 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 `