Files
houseplan-card/docs/specs/174-linked-virtual-light-controller.md
2026-08-18 17:29:48 +00:00

28 KiB
Raw Permalink Blame History

Issue #174 — связанный виртуальный источник следует реальному контроллеру

  • Issue: https://github.com/Matysh/houseplan-card/issues/174
  • Редакция: первая редакция для независимого ревью; статус определяется только метками issue
  • Тип / приоритет: bug / P1
  • Оценка: пользовательская ценность 8/10; ценность для разработки 6/10; сложность и риск 5/10
  • Область: canonical light graph, универсальный Toggle, View/kiosk/touch, device presentation, preview/confirmation и тесты
  • Модель данных: без новых полей и миграции
  • Связано: #84, #94, #107, docs/LIGHT.md, docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md, docs/TOUCH-SUPPORT.md

1. Сценарий и персона

Персона: администратор дома настраивает план, после чего он сам, домочадец или пользователь киоска управляет светом в обычном View.

Поверхность и момент: физическая лампа не имеет собственной сущности HA. На плане она представлена virtual marker с ролью Всегда и действием Переключить состояние. Реальный умный выключатель или реле имеет отдельный marker и в поле Управляет другими источниками света ссылается на эту лампу. Пользователь нажимает либо выключатель, либо изображение лампы.

Задача поддерживает:

  • J1: Glow, заливка «Свет», статистика комнаты и оба marker показывают одно реальное текущее состояние;
  • J3: очевидное безопасное действие с любой из двух пространственных точек действительно переключает физический свет;
  • обязательный View/kiosk/touch-контракт: tap и click не расходятся и не создают отдельное состояние только внутри карточки.

2. Что человек увидит до и после

До: клик по реальному выключателю меняет его HA state, но Glow виртуальной лампы не меняется; клик по лампе меняет только внутреннее ручное состояние House Plan и не переключает физическое реле.

После: связанная пара ведёт себя как два умных представления одного света. Клик по выключателю или лампе переключает реальное реле, а его HA state едино управляет Glow, заливкой, статистикой и состоянием обоих marker. Несвязанная виртуальная лампа сохраняет ручное поведение #107.

3. Проблема и подтверждённая причина

В resolvedLightSources() passive source сначала получает правильное состояние по #84:

source.on = OR(active incoming controller driver entities)

Затем точная тройка #107 — binding=virtual, is_light=true, effective tap_action=toggle — безусловно заменяет результат значением operational Store virtual_lights. Поэтому входящие контроллеры существуют в конфигурации, но не влияют ни на Glow, ни на остальные canonical consumers.

В resolveToggleIntent() та же тройка проверяется раньше controls и всегда возвращает operation virtual-light. Resolver не рассматривает incoming links на текущий marker, поэтому клик по лампе не может построить HA-команду к driver entities контроллеров.

Подтверждённая матрица текущего dev:

Manual state Реальный driver Сейчас source.on
on on on
on off on — driver проигнорирован
off on off — driver проигнорирован
off off off

Это не дубликат:

  • #84 ввёл связь passive lamp → реальные driver entities и OR-состояние;
  • #107 ввёл ручной state и намеренно дал ему абсолютный приоритет;
  • #174 по решению владельца меняет только конфликтующую комбинацию и объединяет её с реальным управлением.

4. Решение владельца

Владелец подтвердил изменение поведения 18.08.2026: https://github.com/Matysh/houseplan-card/issues/174#issuecomment-5330854846

  1. Пара «умный выключатель/реле + virtual lamp» ведёт себя как два умных устройства, когда у обоих выбрано Переключить состояние.
  2. Клик по любому marker включает/выключает свет на плане и реально переключает выключатель/реле.
  3. В linked-режиме HA state реальных driver entities является единственным текущим источником истины; ручной state #107 применяется только без связи.
  4. Рекомендованный в issue default принят как граница lifecycle: создание связи не стирает сохранённый manual state, а снятие последней связи возвращает его.

5. Термины

Термин Значение
Manual-eligible source Active marker точной тройки #107
Incoming link Сохранённый marker:<target_id> в controls другого marker
Linked source Manual-eligible passive source, на который существует хотя бы один валидный incoming link
Controller Marker, содержащий incoming link
Driver entities Реальные effective light.*/switch.*, по которым #84 вычисляет состояние controller
Manual mode Operational virtual_lights state несвязанного manual-eligible source
Linked mode Состояние и действие через driver entities при наличии incoming link

marker:* остаётся только идентификатором графа. Он никогда не становится HA entity ID, не читается из hass.states и не передаётся в callService.

6. Скоуп

В задачу входят:

  1. приоритет linked controller state над manual state для точной тройки #107;
  2. обратное разрешение source → все его incoming controllers → реальные driver entities;
  3. клик/tap по linked virtual source через существующую групповую семантику HA;
  4. сохранение текущего клика по controller и согласование его с тем же driver projection;
  5. HA state updates от физического устройства и автоматизаций без клика House Plan;
  6. несколько controllers, дедупликация driver entities и partial availability;
  7. preview, confirmation и безопасная повторная резолюция цели;
  8. manual fallback после снятия последней связи без потери operational state;
  9. единое состояние для Glow, room fill/counts, device presentation, full/static cards и редакторского preview;
  10. unit, targeted browser smoke, документация и оба changelog.

7. Не входит в задачу

  • новый UI, отдельная кнопка режима или новое поле конфигурации;
  • создание synthetic HA entity/helper либо запись manual state в HA;
  • изменение picker, синтаксиса controls, валидации, import/export или remap;
  • AND/NOT/приоритет между несколькими controllers вместо действующего OR;
  • синхронизация цвета/яркости реального relay с passive lamp;
  • вызов operational houseplan/virtual_light/toggle одновременно с HA service;
  • автоматическое связывание лампы и выключателя;
  • изменение безопасного запрета для lock/alarm/guarded cover;
  • общая переработка universal Toggle вне связи passive source;
  • история состояний, автоматизации или новая диагностическая поверхность.

8. Контракт состояния

8.1. Выбор authority

Для manual-eligible passive source:

if validIncomingLinks.length > 0:
    source.on = any(activeDriverEntity.state == "on")
else:
    source.on = virtualLightIsOn(source, operationalSnapshot)
  • Сам факт валидной сохранённой связи включает linked mode.
  • Если links есть, но ни одного active driver нет, source dormant/off; он не возвращается к manual или constant-on.
  • Несколько controllers используют OR без второго голоса комнаты.
  • HA automation, физическая клавиша либо другой dashboard, изменивший driver, меняет source на следующем HA state update без House Plan WS-команды.
  • virtual_lights revision не может переопределить linked source. Событие Store допустимо инвалидирует cache, но визуальный результат остаётся driver-owned.

8.2. Driver projection

Driver entities определяются ровно по действующему правилу #84:

  1. если controller имеет active реальные entity targets в controls, drivers — эти effective targets;
  2. иначе driver — собственная ведущая controllable entity controller;
  3. missing, HA-disabled, unavailable и неподдерживаемые targets не становятся executable service targets;
  4. одинаковая entity, найденная через несколько controllers/refs, учитывается один раз;
  5. marker:* и passive target не входят в service payload.

Один pure reverse-index/helper является authority и для source.on, и для source Toggle. Две независимо написанные проекции incoming graph запрещены.

8.3. Lifecycle manual state

  • Добавление первой incoming связи немедленно переключает source на driver authority, но не удаляет существующий off-bit.
  • Клик по linked source не читает и не меняет off-bit.
  • Снятие последней связи немедленно возвращает exact сохранённый manual state; если записи нет, действует compatibility default on.
  • Role/action/binding/delete lifecycle по #107 остаётся прежним и может очистить manual state независимо от links.
  • Hidden source сохраняет state, но не рисуется. Hidden controller не разрывает сохранённую физическую связь по контракту #84.
  • Broken ref, target без forced-source роли и self/cycle не создают linked mode в runtime; их lossless/validation-поведение остаётся за #84.

9. Контракт действия

9.1. Клик по controller

Существующий controller Toggle остаётся групповым:

  • если любой доступный target/driver включён — turn_off всех доступных;
  • иначе — turn_on всех доступных;
  • passive marker:* не уходит в HA service;
  • в простой паре команда содержит реальный relay controller;
  • после HA state update связанная лампа меняет все canonical consumers.

Реализация не должна поддерживать визуал отдельным optimistic manual flip.

9.2. Клик по linked virtual source

Для explicit Toggle linked source resolver возвращает обычную typed ha-service operation по объединённым driver entities incoming controllers:

  • any available driver on → turn_off всех available drivers;
  • все available drivers off → turn_on всех available drivers;
  • partial group вызывает service только для показанного available subset и перечисляет пропуски существующим formatter;
  • нет available drivers → объяснённый safe no-op, без fallback к virtual-light и без вызова HA;
  • target preview показывает реальные driver names/current effect, а не operational virtual target;
  • service response сам по себе не меняет visual state; authority — последующий HA snapshot, как для обычного умного света.

Для manual-eligible source без incoming links сохраняется поведение #107: virtual-light operation, server-authoritative operational state, sync между карточками и отсутствие HA service.

9.3. Confirmation и гонки

tap_confirm сохраняет действующий confirmation flow. Перед выполнением intent резолвится заново.

  • смена manual ↔ linked mode во время открытого confirmation меняет kind/target operation и отменяет старое подтверждение как «цель изменилась»;
  • изменение набора driver entities не разрешает тихо вызвать другую группу;
  • изменение только направления turn_on ↔ turn_off при том же target set допустимо и использует актуальный state;
  • исчезновение всех drivers заканчивается safe no-op, без operational fallback.

10. Единые визуальные consumers

Linked source получает один source.on из canonical light resolver. Этот же результат обязателен для:

  • Glow full card и kiosk;
  • room fill «Свет»;
  • resolvedLightState и resolvedLightStats / N из M;
  • marker background/status и controller aggregate presentation;
  • device-editor preview;
  • houseplan-space-card.

Отдельная ветка в SVG, presentation resolver или room card запрещена. Один HA state tick обязан обновить все consumers без config rebuild, operational toggle или polling.

11. Модель данных, compatibility и миграция

Marker, ServerConfig, layout, Lovelace config и backend schema не меняются. Синтаксис controls: [marker:<id>] остаётся форматом #84. Operational Store houseplan.virtual_lights и wire snapshot #107 остаются без изменения.

Compatibility:

Состояние конфигурации Поведение после #174
Exact triple, links отсутствуют Manual state #107 без изменений
Exact triple, valid incoming link Driver-owned linked mode
Exact triple, link снят Прежний manual state снова видим
Не exact triple Обычная семантика #84/#94
Старый frontend/backend Их прежнее поведение; конфигурация не повреждается

Новых migration/compatibility fields, materialization, Store reconciliation и import/export изменений нет. Откат не требует преобразования данных.

12. UX, i18n, accessibility и touch

Новых controls, DOM-поверхностей, focus order и ARIA нет. Используются существующие localized group target/current/next/missing строки universal Toggle; новые RU/EN i18n-ключи не требуются. Если реализация докажет, что существующий formatter не может человекопонятно показать driver group, это считается отдельной продуктовой находкой, а не разрешением молча добавить текст.

View и kiosk на touch — блокирующие:

  • один короткий tap выполняет ровно одну service operation;
  • long-press по-прежнему открывает карточку;
  • pan, pinch, pointercancel и suppressed synthetic click не выполняют Toggle;
  • confirmation остаётся доступным и не меняет target после подтверждения;
  • отсутствие/ошибка service не создаёт unhandled rejection или ложный Glow.

Device editor остаётся desktop-first; меняется только уже существующий preview. Touch editor: best effort, новых редакторских действий нет.

13. Критерии приёмки

  • AC1 (unit): exact triple с incoming controller и manual=off, driver=on даёт source.on=true; manual=on, driver=off даёт false. Возврат безусловного manual override обязан красить тест.
  • AC2 (unit): HA state driver, изменённый без House Plan action, сразу меняет source, room state/stats и marker/controller presentation через один canonical resolver; operational revision не меняет linked result.
  • AC3 (unit): explicit Toggle linked virtual source возвращает ha-service operation к реальным driver entities, а не virtual-light; простой relay получает turn_on/turn_off и никогда не получает marker ID.
  • AC4 (unit): несколько controllers и aliases дают детерминированный deduplicated target set; any-on выключает все, all-off включает все.
  • AC5 (unit): unavailable/missing/HA-disabled subset пропускается и объясняется; при нуле available drivers intent является safe no-op без manual fallback и без service call.
  • AC6 (unit): клик по controller сохраняет #84/#94 group semantics и переключает тот же driver projection, которым вычисляется linked source.
  • AC7 (unit): добавление link не стирает manual off-bit; снятие последней связи возвращает exact прежний state и operation virtual-light. Несвязанные regression tests #107 остаются зелёными.
  • AC8 (unit): confirmation принимает смену направления при неизменном target set, но отказывает при смене manual/linked mode либо набора drivers.
  • AC9 (smoke): в full View реальный click по controller и по linked lamp по очереди вызывает реальное relay, а смоделированный HA state update вместе меняет Glow, room fill/count и presentation; operational WS toggle не вызван.
  • AC10 (smoke): touch tap по linked lamp выполняет один HA service; long-press/pan/pinch/pointercancel не выполняют service и не меняют visual.
  • AC11 (smoke): внешний HA state update/automation меняет связанный свет без клика; unlink возвращает сохранённый manual state и manual toggle #107.
  • AC12 (unit + ревью кода): incoming graph/driver projection реализован одним authority helper, переиспользованным light state и Toggle; renderer-only и дублирующая reverse-graph ветки отсутствуют.
  • AC13 (unit + build): no-link #84 constant-on, unlinked #107, stateful marker targets, direct entity controls, secure no-op и RU/EN parity остаются зелёными; три bundle snapshot побайтово совпадают.
  • AC14 (ревью кода): config/backend/Store/import/export schema не меняются; нет optimistic state, polling, новых network paths или ослабления lock/alarm invariant.

14. План автотестов и гейтов

Unit

  1. Расширить test/devices.test.mjs матрицей manual × driver, automation update, linked/unlinked lifecycle, multiple controllers и cache invalidation.
  2. Расширить test/device-toggle.test.mjs reverse source action, dedupe, any-on/all-off, partial/zero availability и operation identity.
  3. Расширить test/device-presentation.test.mjs общей linked-state проекцией.
  4. Добавить mutation assertions: возврат manual override и запись virtual_lights при linked click обязаны красить тесты.

Targeted browser smoke

Новый demo/smoke_linked_virtual_light.mjs либо однозначно названное расширение существующего controls/Glow smoke строит одну пару relay + virtual lamp, перехватывает service/operational WS calls и доказывает AC9–AC11. Он выполняется локально перед S7-code-review.

Implementation loop

npm run typecheck
npm test
npm run build
node demo/smoke_linked_virtual_light.mjs

После build три поставляемые bundle-копии совпадают byte-for-byte. Python/backend гейт не нужен, если diff действительно не затронет backend. Полные smoke, golden и performance выполняются перед бетой. Нового golden baseline нет: вид уже существующих on/off состояний не меняется, меняется источник их authority.

15. Риски, performance и security

Риск Мера
Light resolver и Toggle выберут разные driver entities. Один reverse-index/helper и AC6/AC12.
Source click при нескольких controllers переключит только один relay. Детерминированный union/dedupe и group tests.
Broken/dormant link неожиданно вернёт manual Glow. Сам факт valid link фиксирует linked mode; zero-driver AC5.
Confirmation вызовет новую цель после изменения config. Сравнение operation target sets, AC8.
HA service response даст преждевременный визуальный flip. Только HA state authority, smoke проверяет отсутствие optimistic update.
Исправление сломает несвязанную #107 лампу. AC7 и существующая backend/frontend suite #107.

Performance: reverse graph уже строится при canonical light resolution. Требуется переиспользовать или кэшировать ту же bounded проекцию по content fingerprint; нельзя добавлять полный O(markers + links) обход на каждый visual consumer либо pointer event сверх одного resolution действия. Нового численного budget нет; pre-release performance smoke остаётся обязательным.

Security/privacy: service targets остаются allow-listed реальными light.*/switch.*, resolver сохраняет unavailable/disabled guards, а marker:* не покидает frontend graph. Новых прав, payload, Store данных и внешних запросов нет. Lock/alarm invariant не затронут.

16. Откат

Откат — обычный revert frontend, тестов, документации, changelog и bundle. Форматы данных не меняются, поэтому migration/cleanup не нужны. Manual state в отдельном Store всё время сохраняется и после отката снова становится authority для exact triple по старому #107.

Feature flag не добавляется: это исправление уже обещанного J1/J3 поведения, а не экспериментальная presentation-функция.

17. Release-артефакты

  • пользовательские записи в docs/CHANGELOG.md и docs/CHANGELOG.ru.md в том же implementation-коммите (User-Visible: yes);
  • docs/USER-GUIDE.ru.md — linked и unlinked recipe;
  • docs/LIGHT.md и docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md — authority и action matrix;
  • supersession note в docs/specs/107-virtual-light-toggle.md для linked-случая;
  • docs/ARCHITECTURE.md — только если меняется публично описанная граница resolver/action modules;
  • unit и targeted browser smoke;
  • синхронные dist/houseplan-card.js, HA frontend и demo bundle;
  • новых screenshots, golden baseline, backend migration, security report и i18n keys не требуется;
  • issue закрывается только после включения в опубликованную бету.

18. Принятые технические предположения

  1. Reverse-index может быть отдельным pure helper либо частью plan-wide light graph cache; его форма не продуктовый контракт, если state и action используют один результат.
  2. Существующий ResolvedLightSource можно расширить driver metadata либо оставить её в соседнем graph object; нельзя выдавать driver за собственный serviceEids passive lamp, если это ломает source/service identity #84.
  3. «Валидный incoming link» означает существующую runtime-связь к активному forced source. Broken/dormant legacy refs сохраняют lossless-поведение #84 и не создают выдуманную HA цель.
  4. Hidden controller продолжает быть физическим driver по #84; hidden target не рисуется, но связь и manual state остаются сохранены.
  5. Operational WS command #107 может оставаться backend-доступным для eligible marker, однако обычный frontend linked action его не вызывает, а linked resolver не читает изменённый off-bit.
  6. Existing group formatter и toast должны переиспользоваться. Если нужен новый пользовательский текст, это отдельная продуктовая находка и возврат в S3-spec, а не техническое предположение.
  7. Source service action не применяет optimistic Glow: задержка до HA state tick совпадает с обычным умным устройством и является частью единого authority.