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

436 lines
28 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 #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:
```text
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:
```text
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
```text
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.