34 KiB
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. Подтверждённое текущее состояние
resolveToggleIntent()передаёт обычный single Toggle вresolveOwnEntity();ownRoleCandidates()строит детерминированную цепочку из binding,light_entity, primary и выбранной functional role.light_entityсейчас намеренно участвует в legacy action resolution: так marker не светится от одной лампы и молча не переключает другое реле.- Диалог строит runtime marker через
_markerDraft()и production pipeline, поэтомуtoggle_hint_*уже может показывать draft-цель до Save. ownControllableEntities()даёт совместимый список собственныхlight.*/switch.*в порядке binding → primary → остальные кандидаты.- При непустом
controlsexplicit Toggle сейчас формирует группу только из внешних целей и не вызываетresolveOwnEntity(). - Паттерн #88 уже хранит
light_entity, показывает предупреждение при stale выборе и временно возвращается к совместимому fallback без стирания поля. - Backend допускает lossless marker fields через схему, но для нового
entity-id требуется отдельная delta-validation, как для
light_entity. - При space import с политикой
virtualHA-зависимые поля marker удаляются явным allowlist; новое поле необходимо добавить в этот путь.
4. Решения владельца
Нормативны решения из описания issue от 18.08.2026: https://github.com/Matysh/houseplan-card/issues/178
- Рабочее и каноническое имя поля —
toggle_entity. - Селектор показывается только при эффективном Toggle и осмысленном выборе.
- Отсутствие поля сохраняет текущую цепочку бит-в-бит; миграции нет.
- Stale выбор сохраняется, показывает предупреждение и временно использует прежнюю цепочку.
- Подсказка цели реагирует немедленно, до сохранения.
- HA entity id переносится буквально.
tap_target,light_entityиtoggle_entity— независимые поля.- При внешних
controlsявно выбранная собственная сущность участвует в группе, но правилоany-on → turn_off allне меняется. - Виртуальные marker и операционные цели
marker:*не получают собственного выбора в рамках #178.
5. Scope
В #178 входят:
- новое optional marker field
toggle_entity; - список собственных активных
light.*/switch.*кандидатов; - селектор и stale-warning в диалоге устройства;
- live-preview выбранной цели через существующие
toggle_hint_*; - explicit single resolution выбранной сущности;
- добавление выбранной собственной сущности в explicit controls-group;
- точное сохранение legacy resolution при отсутствии поля;
- lossless persistence, delta-validation и transfer policy;
- RU/EN локализация, пользовательская и compatibility-документация;
- 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
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:
- exact entity binding, если это
light.*/switch.*; - current primary, если это
light.*/switch.*; - остальные активные registry entities того же HA device в существующем детерминированном порядке;
- дубликаты удаляются с сохранением первого вхождения.
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, но никогда не заменяет валидный explicittoggle_entity.
9. Диалог устройства
9.1 Видимость и расположение
Селектор расположен сразу под marker-tap-action и до текущей Toggle-подсказки.
Он виден, когда одновременно:
- effective action draft равен
toggle; - candidate set содержит минимум две сущности либо сохранённый непустой
toggle_entitystale.
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 выполняется так:
- получить candidate set §8.1;
- если explicit id присутствует в нём — вызвать существующий
resolveEntity()только для него с own-targetvia; - если explicit отсутствует/stale/null — вызвать неизменённый legacy
resolveOwnEntity(); - построить прежний
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 состоят из:
- selected own entity с own-target
via; - всех разрешённых 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 и валидация
- Marker schema принимает optional
toggle_entitylosslessly (objectна schema-level, какlight_entity). - Общий либо отдельный validator проверяет новые/изменённые значения по
light.*/switch.*regexp; равный previous literal не блокирует unrelated save. - Websocket save, config update и import preview/apply вызывают validator на
тех же границах, что
validate_marker_light_entities(). - Full import использует
validate_all=True. - Backend не требует наличия id в registry: transfer между HA instances и временно отсутствующие entities остаются допустимыми.
- Ошибка имеет отдельный стабильный 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:
- UI перестаёт предлагать selector;
- runtime игнорирует
toggle_entityи возвращается к legacy resolution; - backend validation/import additions удаляются вместе с feature;
- уже сохранённый 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
Обязательны тесты:
- два own candidates: explicit child-lock и explicit power дают разные exact single command ids;
- entity binding остаётся exact и не предлагает registry siblings;
- отсутствие поля сохраняет существующие результаты single/device/light_entity fixtures без изменения;
- stale explicit value сохраняет legacy fallback и не вызывает stale id;
- active selected value с unavailable/missing/secure state не retarget-ится;
- один candidate не меняет runtime и корректно определяется UI helper;
- explicit own + external controls формируют одну дедуплицированную группу;
- без explicit поля group остаётся external-only;
- unavailable selected own даёт skip, но валидная external group работает;
- cover, manual virtual light и
marker:*paths не меняются; - touched/untouched/stale write-fields сохраняют own-property contract;
- backend принимает valid delta, отклоняет invalid changed value, пропускает unchanged future literal и валидирует full import;
- virtualized duplicate import удаляет
toggle_entity, обычный transfer сохраняет literal; - RU/EN key parity и native-select contract включают новый control.
18.2 Production-bundle smoke
Новый demo/smoke_toggle_entity.mjs либо эквивалентный именованный smoke
запускается против dist/houseplan-card.js и проверяет:
- composite washer с двумя switch показывает selector при effective Toggle;
- выбор child-lock до Save меняет видимую подсказку и live-region;
- Save пишет
toggle_entity, reopen восстанавливает выбор; - stale fixture показывает warning, сохраняет literal и показывает fallback;
- marker с одной controllable entity не показывает selector;
- 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
В цикле реализации:
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
- При effective Toggle и двух собственных
light.*/switch.*виден новый selector с friendly name и entity id. Доказательство: smoke §18.2.1, desktop golden §18.3. - Выбор сущности немедленно меняет preview hint и после Save — exact service target. Доказательство: unit §18.1.1 и smoke §18.2.2–3.
- При одной кандидатке selector скрыт и поведение прежнее. Доказательство: unit §18.1.6 и smoke §18.2.5.
- Config без
toggle_entityдаёт бит-в-бит прежние single и group targets. Доказательство: unit §18.1.3, §18.1.8 и mutation floor §18.2. - Stale значение не стирается, предупреждается и временно использует legacy fallback. Доказательство: unit §18.1.4/11, smoke §18.2.4 и mobile golden §18.3.
- Active, но transient unavailable selected entity не заменяется sibling. Доказательство: unit §18.1.5/9.
- Explicit selected own entity входит в controls-group; без explicit поля group остаётся external-only. Доказательство: unit §18.1.7–9.
light_entity,tap_target, cover и virtual paths независимы и не изменены. Доказательство: unit §18.1.3/10 и code review diff.- Full/space transfer сохраняет id буквально; virtualize policy удаляет поле. Доказательство: backend/import unit §18.1.13.
- Backend применяет lossless delta-validation. Доказательство: backend unit §18.1.12 и full-import fixture.
- RU/EN, accessibility, touch, unit, named smoke и golden contracts выполнены. Доказательство: i18n/native-select unit §18.1.14, smoke §18.2 и reviewed Linux golden §18.3.
- Оба changelog и пользовательские документы обновлены в том же user-visible commit. Доказательство: commit trailers/process gate и code-review artifact inventory.
20. Принятые технические предположения
- Для списка переиспользуется/обобщается pure candidate helper на основе
ownControllableEntities(); отдельная registry traversal в UI не создаётся. - Новый select следует native-select и touched/write-fields паттернам
light_entity, но поля остаются независимыми. - «Вторичная строка» из issue реализуется существующим для native option
компактным форматом
friendly name · entity_id; custom dropdown вне scope. - Selected own group entry получает own
via, чтобы diagnostics не выдавали её за внешнийcontrolsref. - Существующая конфигурационная ревизия достаточна; отдельный schema version и background migration не нужны.