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

34 KiB
Raw Blame History

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

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-геометрию.

На узкой ширине 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

В цикле реализации:

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 не нужны.