docs: specify linked virtual light control

Issue: #174
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-18 17:29:48 +00:00
committed by claude[bot]
parent e727023bf4
commit dae2efc280
2 changed files with 436 additions and 0 deletions
@@ -0,0 +1,435 @@
# 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.
+1
View File
@@ -93,6 +93,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#157](https://github.com/Matysh/houseplan-card/issues/157) Тип проёма «Открытый проём» | [157-open-passage.md](157-open-passage.md) |
| [#150](https://github.com/Matysh/houseplan-card/issues/150) Точная геометрия коллинеарного перепада толщины | [150-wall-thickness-transition.md](150-wall-thickness-transition.md) |
| [#172](https://github.com/Matysh/houseplan-card/issues/172) Нулевой Split-разделитель не получает ложную толщину | [172-zero-divider-taper.md](172-zero-divider-taper.md) |
| [#174](https://github.com/Matysh/houseplan-card/issues/174) Связанный виртуальный источник следует реальному контроллеру | [174-linked-virtual-light-controller.md](174-linked-virtual-light-controller.md) |
## Правило актуализации