Files
houseplan-card/docs/specs/178-toggle-entity.md
T
2026-08-18 21:31:04 +03:00

544 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.<object_id>` либо
`switch.<object_id>`. Пустая строка из 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 `<select>` и входит в keyboard/accessibility
contract проекта: связанный label, стабильный id `marker-toggle-entity`, без
ловушки фокуса и без обязательного pointer hover.
### 9.3 Транзакционность и live hint
Dialog state хранит value, touched-флаг, own-property presence и original
literal по паттерну `light_entity`.
- Открытие не меняет config.
- Выбор candidate либо Auto помечает поле touched.
- Каждое изменение пересобирает preview marker и немедленно вызывает
существующий `_announceToggleDraft()`.
- Видимая `toggle_hint_*` строка и polite live-region отражают новую exact цель
до Save.
- Cancel ничего не сохраняет.
- Save пишет новое поле только по §7.1 и не меняет `tap_target`, `light_entity`
либо `controls`.
## 10. Runtime single Toggle
Для `explicit-toggle` и `default-light`, если external controls-group не
активирована, resolution выполняется так:
1. получить candidate set §8.1;
2. если explicit id присутствует в нём — вызвать существующий `resolveEntity()`
только для него с own-target `via`;
3. если explicit отсутствует/stale/null — вызвать неизменённый legacy
`resolveOwnEntity()`;
4. построить прежний `singleIntent`.
Selected target не становится soft preference. Если он registry-active, но
сейчас unavailable/missing/secure/unsupported, пользователь получает прежнюю
объяснённую none/skip семантику именно этой сущности; silent retarget запрещён.
Legacy `cover` не использует поле: это исторический exact target другого
домена. Manual virtual-light triple и incoming-controller path также идут до
нового resolution и не меняются.
## 11. Runtime group Toggle
### 11.1 Совместимый режим
Если для `explicit-toggle` активна существующая controls-group, а валидного
активного explicit `toggle_entity` нет, resolution остаётся сегодняшним: группа
состоит только из external refs. Stale value также не меняет group membership.
`default-light` не начинает использовать `controls` только из-за нового поля.
### 11.2 Явно выбранная собственная сущность
Если для `explicit-toggle` controls-group активна и explicit `toggle_entity`
присутствует в candidate set, group entries состоят из:
1. selected own entity с own-target `via`;
2. всех разрешённых external `controls` в существующем порядке.
Дальше без изменений применяются:
- дедупликация по entity id;
- skip diagnostics для битых external refs;
- `turn_off`, если хотя бы одна resolved target сейчас `on`, иначе `turn_on`;
- один `homeassistant.turn_on/turn_off` со списком resolved ids;
- confirmation identity и повторный resolve направления непосредственно перед
service call.
Если selected own entity transient unavailable/missing/secure/unsupported, она
попадает в group skip diagnostics; внешние валидные цели продолжают работать по
существующему partial-group contract. Runtime не подставляет другую own entity.
## 12. Backend и валидация
1. Marker schema принимает optional `toggle_entity` losslessly (`object` на
schema-level, как `light_entity`).
2. Общий либо отдельный validator проверяет новые/изменённые значения по
`light.*`/`switch.*` regexp; равный previous literal не блокирует unrelated
save.
3. Websocket save, config update и import preview/apply вызывают validator на
тех же границах, что `validate_marker_light_entities()`.
4. Full import использует `validate_all=True`.
5. Backend не требует наличия id в registry: transfer между HA instances и
временно отсутствующие entities остаются допустимыми.
6. Ошибка имеет отдельный стабильный code либо обобщённый pluralized code,
однозначно указывающий на invalid toggle entity.
## 13. i18n и документация
RU и EN получают полный паритет для:
- label и help селектора;
- Auto/fallback option;
- «нет доступной сущности»;
- warning о stale id и временном fallback.
Обновить:
- `docs/USER-GUIDE.ru.md` и `docs/USER-GUIDE.md` — выбор цели Toggle и
независимость от источника света;
- `docs/CONFIG-COMPATIBILITY.md` — поле, absence/fallback, lossless validation,
downgrade и transfer contract;
- при необходимости `docs/ARCHITECTURE.md` — раздел action resolution;
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в пользовательском commit.
## 14. Touch и accessibility
**Touch editor: supported.** Функция доступна в существующем диалоге устройства
одинаково на desktop и touch: используется native `<select>` с обычным tap,
связанным `<label>` и без hover-only affordance. Селектор не добавляет жестов
на плане, drag/pinch/pan, long-press либо собственную touch-геометрию.
Runtime-эффект сохранённого выбора одинаков для pointer и touch: тап по marker
в обычном View/kiosk проходит через один `resolveToggleIntent()` и не имеет
отдельной ветки по типу устройства ввода.
На узкой ширине label, option text, help и stale-warning не должны выходить за
границы диалога или перекрывать footer. Длинный friendly name может быть
обрезан нативным control, но полный entity id остаётся в option и warning.
Keyboard focus, screen-reader label, `aria-live` текущей подсказки и
`role="status"` warning проверяются существующим accessibility-паттерном
диалога.
## 15. Производительность, security и риски
### 15.1 Производительность
Нового per-frame, pointermove, SVG либо room-aggregate вычисления нет.
Candidate set строится только при открытом/изменяемом диалоге и при Toggle
resolution линейно по числу сущностей одного устройства; group resolution
остаётся линейным по числу `controls`. Обязательный performance contract:
- отсутствие нового global registry scan на каждый render View;
- отсутствие сетевого запроса при смене select;
- preview использует существующие memo/revision boundaries;
- отдельный benchmark и новый budget не требуются, потому что hot render path
не меняется; prerelease performance suite не должен показать регрессию.
### 15.2 Security
Поле не расширяет набор разрешённых доменов и services. Backend принимает для
новой/изменённой записи только `light.*`/`switch.*`; runtime передаёт exact id в
существующий capability-aware resolver и вызывает только уже разрешённые
`turn_on`/`turn_off`/`toggle` paths. `lock.*`, secure domains, шаблоны, service
names и произвольные payload из поля невозможны. Новых permissions, endpoints
и секретов нет.
### 15.3 Риски и меры
| Риск | Последствие | Мера и доказательство |
|---|---|---|
| Новый шаг случайно меняет config без поля | массовая смена tap targets после обновления | отдельные legacy single/group regression units и отсутствие миграции |
| Explicit own entity ошибочно добавляется в каждую controls-group | старые marker начинают переключать собственное питание | group membership меняется только при active explicit value; mutation unit §18.1 |
| Stale literal стирается при unrelated save | выбор не восстанавливается после возврата entity | touched/write-fields unit + dialog smoke reopen |
| Selected unavailable entity silent-retarget-ится | тап действует на неожиданное sibling-реле | exact-target unavailable/missing/secure units |
| Frontend/backend разных версий расходятся | старый клиент игнорирует или стирает unknown field при rebuild marker | optional/lossless delta contract и явная downgrade-записка в compatibility docs |
| Длинные имена ломают mobile dialog | selector/warning нечитаемы | mobile RU/dark golden и native select contract |
Остаточный риск — **низкий/средний**: service path существующий, но ошибка в
границе explicit/legacy способна переключить физически другое безопасное
`light`/`switch` устройство. Поэтому exact target и legacy group mutation floor
блокируют перевод в code review.
## 16. Откат
Функция откатывается обычным revert user-visible implementation commit без
data migration:
1. UI перестаёт предлагать selector;
2. runtime игнорирует `toggle_entity` и возвращается к legacy resolution;
3. backend validation/import additions удаляются вместе с feature;
4. уже сохранённый optional literal не требует преобразования для чтения
старой конфигурации; старый frontend/backend может его игнорировать и, как
любой неизвестный marker field, стереть только при реконструкции marker.
Если откат нужен только из-за UI, допускается временно скрыть selector и
оставить read/runtime поддержку поля: это сохраняет пользовательский выбор и
не требует config rewrite. Автоматически переписывать `toggle_entity` в
`light_entity`, `tap_target` либо `controls` при любом варианте отката
запрещено.
После rollback обязательны legacy unit suite, typecheck/build и smoke обычного
Toggle; новый golden удаляется/возвращается в том же reviewed revert.
## 17. Release-артефакты
User-visible implementation commit одновременно включает:
- записи в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- обновления `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md` и
`docs/CONFIG-COMPATIBILITY.md`;
- при изменении action architecture — синхронную правку
`docs/ARCHITECTURE.md`;
- новый named production-bundle smoke и unit/backend coverage;
- golden matrix entries и semantic prerequisites.
Reviewed PNG baselines принимаются только из Linux CI artifact по обычному
prerelease-процессу. Новый performance budget и security report не создаются:
hot path, permissions и API surface не расширяются; доказательством служат
§15, общий prerelease performance gate и code review.
## 18. Тестовый контракт
### 18.1 Unit
Обязательны тесты:
1. два own candidates: explicit child-lock и explicit power дают разные exact
single command ids;
2. entity binding остаётся exact и не предлагает registry siblings;
3. отсутствие поля сохраняет существующие результаты single/device/light_entity
fixtures без изменения;
4. stale explicit value сохраняет legacy fallback и не вызывает stale id;
5. active selected value с unavailable/missing/secure state не retarget-ится;
6. один candidate не меняет runtime и корректно определяется UI helper;
7. explicit own + external controls формируют одну дедуплицированную группу;
8. без explicit поля group остаётся external-only;
9. unavailable selected own даёт skip, но валидная external group работает;
10. cover, manual virtual light и `marker:*` paths не меняются;
11. touched/untouched/stale write-fields сохраняют own-property contract;
12. backend принимает valid delta, отклоняет invalid changed value, пропускает
unchanged future literal и валидирует full import;
13. virtualized duplicate import удаляет `toggle_entity`, обычный transfer
сохраняет literal;
14. RU/EN key parity и native-select contract включают новый control.
### 18.2 Production-bundle smoke
Новый `demo/smoke_toggle_entity.mjs` либо эквивалентный именованный smoke
запускается против `dist/houseplan-card.js` и проверяет:
1. composite washer с двумя switch показывает selector при effective Toggle;
2. выбор child-lock до Save меняет видимую подсказку и live-region;
3. Save пишет `toggle_entity`, reopen восстанавливает выбор;
4. stale fixture показывает warning, сохраняет literal и показывает fallback;
5. marker с одной controllable entity не показывает selector;
6. RU и EN сценарии не содержат отсутствующих ключей.
Mutation floor:
- игнорирование valid `toggle_entity` обязано сломать unit exact-target;
- удаление stale warning обязано сломать smoke;
- ошибочное добавление own entity в legacy external-only group обязано сломать
compatibility unit.
### 18.3 Golden
Golden matrix получает два reviewed сценария одного composite fixture:
- desktop EN/light — selector с двумя кандидатами и выбранной сущностью;
- mobile RU/dark — stale warning и fallback.
Сценарии захватывают dialog целиком, проверяют непустые painted pixels и
стабильные semantic prerequisites. Baseline принимается только из Linux CI
artifact по общему release-процессу; локальный Windows capture не является
основанием для `golden:accept`.
### 18.4 Gates
В цикле реализации:
```text
npm run typecheck
npm test
npm run build
```
Перед `S7-code-review` дополнительно запускается именованный production-bundle
smoke §18.2. Golden, полный smoke set и performance остаются prerelease gates.
Полный HA harness канонически запускается в Linux CI.
## 19. Acceptance criteria
1. При effective Toggle и двух собственных `light.*`/`switch.*` виден новый
selector с friendly name и entity id. **Доказательство:** smoke §18.2.1,
desktop golden §18.3.
2. Выбор сущности немедленно меняет preview hint и после Save — exact service
target. **Доказательство:** unit §18.1.1 и smoke §18.2.2–3.
3. При одной кандидатке selector скрыт и поведение прежнее. **Доказательство:**
unit §18.1.6 и smoke §18.2.5.
4. Config без `toggle_entity` даёт бит-в-бит прежние single и group targets.
**Доказательство:** unit §18.1.3, §18.1.8 и mutation floor §18.2.
5. Stale значение не стирается, предупреждается и временно использует legacy
fallback. **Доказательство:** unit §18.1.4/11, smoke §18.2.4 и mobile golden
§18.3.
6. Active, но transient unavailable selected entity не заменяется sibling.
**Доказательство:** unit §18.1.5/9.
7. Explicit selected own entity входит в controls-group; без explicit поля
group остаётся external-only. **Доказательство:** unit §18.1.7–9.
8. `light_entity`, `tap_target`, cover и virtual paths независимы и не изменены.
**Доказательство:** unit §18.1.3/10 и code review diff.
9. Full/space transfer сохраняет id буквально; virtualize policy удаляет поле.
**Доказательство:** backend/import unit §18.1.13.
10. Backend применяет lossless delta-validation. **Доказательство:** backend
unit §18.1.12 и full-import fixture.
11. RU/EN, accessibility, touch, unit, named smoke и golden contracts выполнены.
**Доказательство:** i18n/native-select unit §18.1.14, smoke §18.2 и reviewed
Linux golden §18.3.
12. Оба changelog и пользовательские документы обновлены в том же
user-visible commit. **Доказательство:** commit trailers/process gate и
code-review artifact inventory.
## 20. Принятые технические предположения
1. Для списка переиспользуется/обобщается pure candidate helper на основе
`ownControllableEntities()`; отдельная registry traversal в UI не создаётся.
2. Новый select следует native-select и touched/write-fields паттернам
`light_entity`, но поля остаются независимыми.
3. «Вторичная строка» из issue реализуется существующим для native option
компактным форматом `friendly name · entity_id`; custom dropdown вне scope.
4. Selected own group entry получает own `via`, чтобы diagnostics не выдавали
её за внешний `controls` ref.
5. Существующая конфигурационная ревизия достаточна; отдельный schema version и
background migration не нужны.