Files
houseplan-card/docs/specs/084-passive-forced-light-sources.md

42 KiB
Raw Permalink Blame History

#84 — Passive forced light sources and smart-switch links

Статус: реализовано локально вместе с #88; ожидает prerelease/CI gate (ревизия 2, реализация 2026-08-11) Issue: https://github.com/Matysh/houseplan-card/issues/84 Канонический файл: docs/specs/084-passive-forced-light-sources.md Связанные решения: #55, #65, #66, #67, #73, #50 (перенос конфигурации), #88 (выбор ведущей сущности — делать вместе), #85 (тесты должны уметь падать) Область: модель источников света, диалог устройства, связи контроллеров, канонический light resolver, совместимость конфигурации


0. Что изменено ревью 2026-08-11

Каждое утверждение ревизии 1 сверено с кодом. Ниже — только расхождения и принятые решения.

# Находка Решение
R1 Импорт пространства порвёт все marker:-ссылки. #50 переименовывает id каждого маркера, а controls копируется буквально: в import_export.py слово controls встречается ровно один раз — в списке полей, которые удаляются при политике дубликатов virtual (:487); ТЗ #50 §9.1 прямо фиксирует marker.controls[] как «сохранить буквально» Новый §7.5. marker:-ссылки ремапятся вместе с id при импорте пространства; ссылки за пределы импортируемого пространства отбрасываются с отметкой в preview; полный импорт не меняет ничего. Правку надо внести в ТЗ #50 сейчас, пока его реализация не закоммичена
R2 Cross-space связи не решены. §5.3 разрешает разные комнаты, про разные пространства ТЗ молчит, а от этого зависит и picker, и перенос Нормативно: связь допускается в пределах одного плана, включая разные пространства (§5.3). Space export/import её наружу не переносит (§7.5)
R3 Capability всё ещё выводится из transient state — ровно там, где §4.2 это запрещает. devices.ts:275: return forced && hass.states?.[forced] ? [{ eid: forced, via: 'forced' }] : [] — исчезнувший из снапшота HA entity делает stateful-источник passive §4.2 получает адрес правки: правило меняется в ownLightCandidatesOf, а не в диалоге; capability определяется binding + доменом (isControllable, logic.ts:1728), состояние — отдельно
R4 Дедупликация и on завязаны на eid. resolvedLightSources ведёт const byEntity = new Map<string, number>() (devices.ts:333) и считает on: hass.states[eid]?.state === 'on' (:341) — у passive-источника eid нет вообще §8.1: ключом карты становится source key (entity:* | marker:*); on для passive вычисляет резолвер (§5), а не hass.states
R5 marker:* отфильтруется до резолвера. effectiveMarkerControls (devices.ts:214) прогоняет список через isControllable, который пропускает только light./switch. Это и есть гарантия downgrade-safety из §7.2, поэтому фильтр НЕ трогаем. Граф читает persistedExternalControls (lossless, devices.ts:231), а effectiveMarkerControls остаётся списком service targets (§8.3). Иначе «починка» фильтра отправит marker:* в callService
R6 Схему бэкенда менять не надо, но валидации нет вообще. vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))) (validation.py:644), где _TEXT = vol.All(str, vol.Length(max=MAX_TEXT)) (:118) — marker:<id> проходит уже сегодня §7.3: схема остаётся пермиссивной; семантическая проверка применяется только к записям, добавляющим или меняющим ссылку. Чтение и round-trip битой ссылки не падают — иначе план, где лампу удалили вручную, станет несохраняемым
R7 Цикл может приехать импортом. Проверка графа только на пути диалога оставляет дыру §7.3: та же проверка выполняется в preview импорта (#50) на merged-конфиге, до выдачи apply-токена
R8 «Memoized per config revision» — известная мина. В проекте _cfgEpoch отстаёт от правок геометрии in-place; на этом уже ломался кэш барьеров света, и test/golden-matrix.test.mjs нормативно требует контентный отпечаток и запрещает _cfgEpoch в glow-слое §13: индекс графа кэшируется по контентному отпечатку набора маркеров (id + role + controls), не по эпохе конфигурации
R9 Не названо следствие §5.1. resolvedLightState (devices.ts:366) возвращает on, если включён хотя бы один источник, а resolvedLightStats (:373) считает passive и в total, и в on §5.1 получает явный абзац: комната с passive-источником без контроллеров никогда не показывает «0 из N» и её заливка «Свет» всегда активна. Это цена детерминированного Always; выход — связать источник с контроллером
R10 §4.1 частично уже выполнен задачей #68. Светоблок разделён на два fieldset (houseplan-card.ts:15916 без disabled, :15931 с ?disabled=${glowControlsDisabled}), радиус вынесен наружу §4.1 переписан под фактическое состояние: остаётся расщепить glowControlsDisabled (:15721) на два предиката — «источника нет вообще» (гасит всё) и «нет собственного состояния» (гасит только «Из источника»)
R11 §14 ссылается на несуществующий документ. В docs/ есть только USER-GUIDE.ru.md; английского гайда нет §14: RU-гайд + английский раздел в README.md
R12 ТЗ не зарегистрировано в docs/specs/README.md, который объявляет себя реестром ТЗ Строка добавлена в таблицу P2

1. Контекст и отменяемое правило

После реализации #65–#67 роль marker.is_light: true («Всегда источник света») всё ещё не создаёт источник без собственной controllable-сущности HA. Текущая цепочка такова:

  1. forcedLightEntityOf() (devices.ts:259) ищет entity binding, primary либо первую собственную controllable-сущность;
  2. без неё ownLightCandidatesOf() (:269) возвращает пустой список;
  3. selectSpatialGlowSource() (:356) возвращает null;
  4. диалог гасит контролы цвета, яркости и радиуса (houseplan-card.ts:15721).

Это поведение было нормативно закреплено в §4.4 067-light-source-controls.md (строка таблицы «Всегда без собственной controllable-сущности → disabled»). Настоящее ТЗ отменяет только это правило. Остальные решения #67 — glow_color, perceptual brightness, dormant override, Auto/Never, ownership и resolvedLightSources() как единый источник истины — сохраняются.

Проблема не ограничена virtual marker. Она возникает у sensor.*, неуправляемой сущности, устройства без подходящей primary-сущности и любой другой привязки, которую пользователь осознанно назначил источником.

Зеркальная задача — #88. Там, где управляемая сущность есть, но их несколько, forcedLightEntityOf молча берёт controllable[0]: свечением может начать управлять детская блокировка вместо питания. #84 разблокирует случай «сущности нет вообще», #88 даёт выбор, когда их много. Обе правят одну цепочку (forcedLightEntityOf → ownLightCandidatesOf → ResolvedLightSource) и один блок диалога, поэтому делаются вместе: иначе светоблок и golden переделываются дважды.

2. Пользовательский сценарий и цель

Основной сценарий:

  • в стене установлен умный выключатель/реле;
  • физическая лампа «тупая» и отсутствует в Home Assistant как отдельная сущность;
  • на плане нужны два разных объекта: выключатель в месте установки и лампа в фактическом месте свечения;
  • состояние Glow берётся от выключателя, но позиция, радиус, цвет и яркость принадлежат лампе.

Цель — разрешить пользователю создать virtual marker лампы, выбрать «Всегда источник света», настроить его Glow и связать его с умным выключателем через существующий раздел «Управляет другими источниками света».

3. Термины

Термин Определение
Forced source Сохранённый marker с is_light: true
Stateful forced source Forced source с собственной активной controllable HA-сущностью
Passive forced source Forced source без собственной controllable HA-сущности
Controller Marker, в controls которого выбрана другая сущность либо forced-source
Driver entities Реальные HA entity IDs, по состоянию которых определяется controller
Marker target Ссылка marker:<marker_id> внутри controls
Service target Реальный HA entity ID, который разрешено передать в callService

Always означает безусловную классификацию marker как пространственного источника. Для passive source отсутствие собственной HA-сущности больше не отменяет классификацию.

4. Нормативная матрица роли и UI

Роль и capability Источник существует «Из источника» Ручной цвет Ручная яркость Радиус
Auto, resolver нашёл own source да active active active active
Auto, own source не найден нет disabled disabled disabled disabled
Never нет disabled disabled disabled disabled
Always + stateful да active active active active
Always + passive да disabled active active active

4.1 Always + passive (уточнено ревью, R10)

Задача #68 уже разделила светоблок: fieldset радиуса (houseplan-card.ts:15916) не наследует disabled, а группа режимов (:15931) наследует. Осталось сделать гейтинг режимным:

  • glowControlsDisabled (:15721, сейчас d.lightRole === 'never' || !spatialSource) расщепляется на два предиката: источника нет вообще (Auto без источника, Never — гасит всю группу) и нет собственного состояния (Always + passive — гасит только «Из источника»);
  • радиокнопка «Из источника» остаётся видимой, получает настоящий disabled и aria-describedby;
  • подсказка: «У устройства нет собственного источника данных. Задайте цвет и яркость вручную» (RU/EN полноценными ключами, без конкатенации);
  • «Задать цвет» и «Задать цвет и яркость» доступны;
  • hp-color-opacity, brightness range и radius input не наследуют disabled группы режимов;
  • для passive source режим «Задать цвет» использует effective brightness 100 %, потому что живого brightness нет;
  • при отсутствии сохранённого glow_color effective fallback равен fill_colors.glow_light.c (houseplan-card.ts:10150), brightness 1, общий radius;
  • если диалог открывает уже сохранённый passive source без glow_color, UI показывает effective ручной режим «Задать цвет и яркость» с fallback, но glowTouched остаётся false. Open → Save без касания не материализует glow_color;
  • явный переход пользователя в Always допускает такой же draft fallback; сохранение роли не обязано сохранять override, пока пользователь не менял ручные значения;
  • существующий валидный glow_color восстанавливается по обычным правилам.

4.2 Capability нельзя выводить из transient state (уточнено, R3)

unavailable/unknown или краткое отсутствие HA snapshot не переводит stateful source в passive. Capability определяется binding и доменом (isControllable, logic.ts:1728), а состояние — отдельно.

Нормативно: правку делать в ownLightCandidatesOf (devices.ts:269-276), где сегодня стоит forced && hass.states?.[forced], а не в диалоге и не в рендерере. Отсутствие entity в снапшоте даёт источник в состоянии «неизвестно/off» по правилам #73, но не меняет классификацию.

HA-disabled binding следует принятой политике disabled devices: marker скрыт и не участвует в light model, но конфигурация сохраняется. К passive source понятие HA-disabled неприменимо: у него нет собственной сущности.

5. Состояние passive source

5.1 Без контроллеров

Passive forced source без сохранённых controller links считается постоянно включённым. Это единственное детерминированное значение явного Always, когда источника состояния нет.

Он:

  • создаёт Glow в позиции marker;
  • участвует в room light state и N из M один раз;
  • использует ручные/fallback color, brightness и radius;
  • не создаёт HA service action сам по себе.

Следствие, которое нужно принять сознательно (R9): resolvedLightState (devices.ts:366) вернёт on для любой комнаты с таким источником, а resolvedLightStats (:373) посчитает его и в on, и в total. Значит комната с несвязанной «тупой» лампой никогда не покажет «0 из N» и её заливка «Свет» всегда активна. Единственный выход для пользователя — связать источник с контроллером (§5.2). Это должно быть написано в пользовательской документации рядом с рецептом «тупая лампа».

5.2 С контроллерами

Если на passive source ссылается хотя бы один сохранённый controller, состояние источника определяется контроллерами:

passiveSource.on = any(activeController.driverEntities is on)
  • Семантика нескольких контроллеров — OR.
  • У controller с реальными entity targets в controls driver entities равны этим effective targets (effectiveMarkerControls, devices.ts:214).
  • Если реальных entity targets нет, используется собственная primary controllable-сущность controller.
  • Marker target не становится driver entity и не участвует в рекурсии.
  • Если links существуют, но после settle нет ни одного активного driver, источник dormant/off, а не «постоянно включён».
  • Краткий разрыв HA использует visual continuity #73/последнее устойчивое состояние и не должен мигать on/off.

5.3 Ownership и границы связи

Свойство Владелец
Положение и комната Glow marker лампы-цели
Цвет, яркость, радиус marker лампы-цели
On/off effective controller drivers либо constant-on без links
HA service call только реальные service targets/controller binding
Room statistics vote лампа-цель, один раз

Выключатель в комнате A может управлять лампой в комнате B. Свет и статистика относятся к B; положение выключателя не создаёт второе пятно.

Нормативно (R2): связь допускается и между разными пространствами одного плана — этаж с выключателем и этаж с лампой встречаются в реальных домах. Ограничение одно: перенос между инстансами такие связи наружу не выносит (§7.5).

6. Picker «Управляет другими источниками света»

6.1 Список кандидатов

Picker объединяет:

  1. существующие доступные light.*/switch.* HA entities (тот же предикат isControllable, что и в рантайме);
  2. все сохранённые plan markers с is_light: true — из всех пространств плана.

Forced-source отображается в группе «Источники на плане» с:

  • именем marker;
  • иконкой;
  • комнатой и пространством;
  • признаком «виртуальный»/«без собственной сущности», когда применимо.

Поиск работает по имени, entity ID (если есть), комнате и пространству.

6.2 Фильтрация и дедупликация

  • Текущий marker не может выбрать себя.
  • Removed target не показывается.
  • HA-disabled target не доступен для нового выбора.
  • User-hidden target не предлагается как новая цель, но уже сохранённый chip остаётся видимым как dormant/hidden и не стирается.
  • Stateful forced-source и его own entity отображаются одной строкой — предпочтительно как plan source, чтобы связь переживала rebind.
  • Existing direct entity control остаётся валидным и не переписывается при нетронутом Save.
  • Entity ref + marker ref одного effective stateful source дают один service target и один room vote.

6.3 Chips

Chip marker target показывает имя, а не сырой marker:*. Для broken/dormant ссылки chip остаётся с warning и кнопкой удаления. Неизвестная ссылка не должна молча исчезать при Open → Save.

7. Модель данных и совместимость

7.1 Формат

Нового параллельного массива не вводить. Расширяется существующий список:

markers:
  - id: smart_switch
    controls:
      - light.real_group
      - marker:v_dumb_bulb
  - id: v_dumb_bulb
    binding: virtual
    is_light: true
    glow_color:
      c: "#ffd58a"
      bri: 0.75

Тип:

type LightControlRef = EntityId | `marker:${string}`;

7.2 Почему controls, а не новое поле (подтверждено кодом, R5)

  • Это одна пользовательская коллекция «какими источниками управляет marker».
  • Сохраняется текущая UI-модель.
  • persistedExternalControls() (devices.ts:231) фильтрует только собственную сущность marker, поэтому любые неизвестные строки переживают Open → Save без изменений — round-trip для marker:* работает уже сегодня.
  • effectiveMarkerControls() (:214) прогоняет список через isControllable, который пропускает только light./switch. — значит старый фронтенд физически не может отправить marker:* в callService.

Запрещено «чинить» isControllable под новый префикс: этот фильтр и есть граница между source identity и service identity (§8.1).

7.3 Валидация (уточнено, R6, R7)

Схема бэкенда не меняется: controls уже принимает произвольные строки (validation.py:644, _TEXT ≤ 500 символов, MAX_CONTROLS = 200). Новой является семантическая проверка, и у неё строгая область применения.

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

  • значение — валидный entity ID либо marker:<non-empty-id>;
  • marker target существует в том же config package;
  • target не равен controller;
  • target имеет is_light: true для новой связи;
  • дубликаты отклоняются/нормализуются детерминированно;
  • граф marker refs не содержит циклов;
  • применяются существующий лимит MAX_CONTROLS и общий лимит payload.

Не проверяется и никогда не отклоняется:

  • чтение конфигурации с битой ссылкой (лампу могли удалить руками или YAML-правкой) — runtime игнорирует ссылку, диагностика показывает проблему, round-trip остаётся lossless. Иначе план становится несохраняемым.

Проверка цикла и существования target выполняется также в preview импорта (#50) на merged-конфиге, до выдачи apply-токена — иначе цикл приезжает файлом мимо диалога.

7.4 Lifecycle

  • Delete target атомарно удаляет его refs из всех controllers.
  • Rebind/смена marker id атомарно переписывает refs old → new.
  • Move/room reassignment не меняет refs.
  • Always → Auto/Never не удаляет refs: они становятся dormant, chip получает warning, возврат в Always восстанавливает связь.
  • Повторная HA-активация/доступность восстанавливает relation без Save.
  • Delete controller удаляет его links; passive target без других links снова работает как constant-on explicit Always.

7.5 Перенос между инстансами (новое, R1/R2)

marker:-ссылка — это внутренний идентификатор плана, а не HA-сущность, поэтому она обязана следовать правилам remap ТЗ #50, а не правилу «сохранить буквально».

Операция Поведение
Полный экспорт/импорт ничего не меняется: id маркеров сохраняются
Экспорт пространства marker:-ссылки на маркеры того же пространства экспортируются; ссылки наружу отбрасываются и считаются в preview отдельной строкой
Импорт пространства marker:-ссылки переписываются по той же immutable remap map, что и marker.id; ссылка, чей target не попал в импорт, удаляется, а не остаётся висеть
Политика дубликатов virtual уже сегодня удаляет controls целиком (import_export.py:487) — поведение сохраняется, в preview это отражается как потеря связей
Entity-ссылки внутри того же controls остаются буквальными, как и раньше

Это требует правки ТЗ #50 §9.1: строка marker.controls[] перестаёт быть целиком «буквальной» и делится по префиксу значения. Правку нужно внести до коммита реализации #50, иначе первая же выпущенная версия перенесёт битые ссылки, и понадобится миграция.

8. Канонические runtime resolvers

8.1 Нельзя выдавать marker ref за HA entity (уточнено, R4)

Текущая ResolvedLightSource (devices.ts:193) построена вокруг обязательного eid, а resolvedLightSources (:327) дедуплицирует источники через const byEntity = new Map<string, number>() (:333) и вычисляет on: hass.states[eid]?.state === 'on' (:341). Для passive source нужно явное разделение понятий:

interface ResolvedLightSource {
  key: `entity:${string}` | `marker:${string}`;
  stateEids: string[];
  serviceEids: string[];
  device: LightSourceDevice;
  via: "controls" | "forced" | "light";
  castsGlow: boolean;
  on: boolean;
}

Точные имена можно изменить при реализации, но инварианты обязательны:

  • source identity не равна service identity;
  • дедупликация идёт по key, а не по eid: карта byEntity заменяется картой по source key;
  • on для passive source вычисляет резолвер по правилам §5, а не hass.states[...];
  • marker:* никогда не попадает в hass.states[...] как будто это entity и никогда не передаётся в callService;
  • passive source может иметь пустые stateEids и serviceEids;
  • stateful marker target резолвится в реальные service entities;
  • via остаётся трёхзначным: passive — это forced с castsGlow: true, четвёртого значения не вводить.

8.2 Один resolver для всех consumers

resolvedLightSources() и связанные pure helpers остаются единственным источником истины для:

  • независимого Glow overlay;
  • заливки «Свет»;
  • room light state;
  • resolvedLightStats/N из M;
  • room card/controls;
  • preview устройства.

Запрещено реализовать passive-only ветку непосредственно в renderer или диалоге. selectSpatialGlowSource() должен принимать passive source, а resolveGlowValues() (logic.ts:1679) — получать marker override и optional state attributes.

8.3 Control action

Перед вызовом HA строится отдельный дедуплицированный список serviceEids; его источник — effectiveMarkerControls(), который по-прежнему пропускает только light./switch. (§7.2).

  • Stateful marker target добавляет свою controllable entity.
  • Passive marker target не добавляет service target.
  • Если список controls содержит только passive targets, обычный Toggle controller продолжает переключать его own primary entity.
  • Если есть реальные external controls, сохраняется текущая group semantics: any-on → turn_off all, иначе turn_on all. Passive targets следуют этому effective aggregate.
  • Пустой список service entities не вызывает callService.

9. Цвет и яркость runtime

  • Stateful source: текущая цепочка rgb_color → kelvin → palette и live brightness, если не перекрыты glow_color.
  • Passive source: live attributes отсутствуют; glow_color.c либо palette, glow_color.bri либо 1.
  • Mode «Задать цвет» на passive source: manual color + brightness 1.
  • Mode «Задать цвет и яркость»: оба manual slots.
  • Глобальный alpha ceiling и perceptual curve #67 не меняются.
  • Анимация появления/исчезновения 0.5 s и правила LIGHT.md/#73 сохраняются.

10. Edge cases

  1. Virtual lamp без controller: постоянно включена (см. следствие §5.1).
  2. Один switch controller: следует его own primary.
  3. Controller с группой HA lights: следует any(on) группы и общей toggle-semantics.
  4. Несколько controllers: OR без двойного room vote.
  5. Controller и lamp в разных комнатах: Glow/статистика в комнате lamp.
  6. Controller и lamp в разных пространствах: то же; связь допустима (§5.3).
  7. Target перемещён: только Glow position меняется.
  8. Target скрыт: link сохранён, Glow отсутствует; chip dormant.
  9. Target HA-disabled: по disabled-device policy не влияет; link dormant.
  10. Controller unavailable: continuity, затем inactive; не constant-on.
  11. Target Always → Never/Auto-no: link не стирается, source отсутствует.
  12. Target снова Always: link оживает автоматически.
  13. Stateful → passive rebind: link сохраняется, переходит на controller drivers и manual/fallback Glow.
  14. Passive → stateful rebind: link сохраняется, service/state резолвятся в HA entity; «Из источника» становится доступным.
  15. Entity ref + marker ref: один service call/source vote.
  16. Broken ref: нет падения и service call; diagnostics + lossless chip.
  17. Self/cycle: UI не предлагает, backend отклоняет ручной payload и импортируемый файл (§7.3).
  18. Save without touch: не материализует fallback и не чистит dormant refs.
  19. Cancel: не меняет role, refs или glow override.
  20. Импорт пространства с внутренней связью: обе стороны переименованы, связь сохранена (§7.5).
  21. Импорт пространства со связью наружу: ссылка отброшена и показана в preview, а не сохранена битой (§7.5).

11. UX, i18n и accessibility

  • Все новые строки RU/EN; parity gate обязателен.
  • Для disabled «Из источника» объяснение видимо текстом, а не только title; при наличии механизма #68 объяснение подаётся через <hp-help>, а не новым паттерном.
  • Радиогруппа сохраняет accessible name; disabled опция имеет настоящее disabled состояние.
  • Picker и chips доступны с клавиатуры; удаление chip имеет локализованный accessible label.
  • Candidate row имеет достаточный touch target и не создаёт горизонтальный scroll в мобильном диалоге.
  • Live preview показывает effective Glow result, но не полную геометрию пула.
  • Изменение HA state не перехватывает focus и не спамит screen reader.

12. Acceptance criteria

  1. is_light: true всегда создаёт forced source для активного marker, даже без собственной controllable-сущности.
  2. Always + passive разблокирует manual color, brightness и radius.
  3. «Из источника» для Always + passive остаётся disabled с объяснением.
  4. Правило работает для virtual, sensor и любой иной non-source binding.
  5. Классификация не меняется от пропажи entity из снапшота HA (§4.2).
  6. Forced markers доступны в controls picker других markers, включая другие пространства.
  7. Virtual dumb bulb может следовать smart switch и светить в собственной позиции/комнате.
  8. Unlinked passive Always source считается on, и это честно отражено в документации (§5.1).
  9. Несколько controllers дают OR; source/statistics не дублируются.
  10. Marker ref никогда не уходит в HA service call.
  11. Stateful marker target реально переключается через resolved entity.
  12. Delete/rebind/role/hidden/disabled lifecycle соответствует §7.4/§10.
  13. Импорт пространства сохраняет внутренние связи и не создаёт битых внешних (§7.5).
  14. Existing entity controls сохраняют поведение и round-trip.
  15. Старый frontend не уничтожает marker refs при untouched save.
  16. Битая ссылка не мешает сохранить план.
  17. Все light consumers получают один и тот же результат canonical resolver.
  18. RU/EN, keyboard, touch, mobile footer и screen reader покрыты.

13. Тест-план

Unit

  • Матрица role × stateful/passive/virtual/sensor/unavailable/disabled.
  • Capability helper без transient misclassification: entity пропал из hass.states → источник остаётся stateful (регрессия на devices.ts:275).
  • Manual/fallback color, bri, radius и dormant override.
  • Graph: no controller, one, many/OR, self, cycle, broken ref.
  • Dedupe direct entity + marker ref; дедупликация по source key, а не по eid.
  • Controller drivers and service target projection; marker:* не попадает в serviceEids ни при какой комбинации.
  • Room ownership across different rooms/spaces.
  • resolvedLightStats one vote per passive source.

Persistence/backend

  • Schema accepts entity ID and valid marker ref (без изменения схемы).
  • Reject syntax/self/cycle/nonexistent target/duplicate/limit overflow на записи, добавляющей ссылку.
  • Чтение и сохранение конфигурации с битой ссылкой не отклоняются.
  • Lossless round-trip broken/dormant refs.
  • Atomic target delete cleanup; atomic id rewrite on rebind.
  • Импорт (#50): внутренняя связь переименована вместе с id; внешняя отброшена и посчитана в preview; цикл в импортируемом файле отклонён до выдачи токена.
  • Downgrade fixture: prior frontend ignores runtime ref but preserves string.

Component/smoke

  • Picker groups, search, candidate labels and chips.
  • Always + passive gating; Auto/Never/stateful regressions.
  • Live draft switching binding/role without Save.
  • Smart switch toggle drives virtual lamp preview/runtime.
  • Save/Cancel and no silent materialization.
  • Hidden/disabled warning chips.
  • Mobile dialog without horizontal scroll/footer regression.

Golden/performance

  • Dumb lamp and smart switch in different rooms, on/off, dark/light.
  • Mobile dialog with passive target selected.
  • Индекс графа кэшируется по контентному отпечатку маркеров (id + role + controls), а не по _cfgEpoch: правка controls in-place обязана инвалидировать кэш (R8). Тест обязан краснеть, если ключ заменить на эпоху.
  • No additional full device rebuild or graph traversal per source per render.

Мутанты (контракт #85)

Задача не считается выполненной, пока не показано, что тесты краснеют при:

  1. возврате hass.states-проверки в capability (§4.2);
  2. дедупликации по eid вместо source key (§8.1);
  3. попадании marker:* в serviceEids (§8.3);
  4. отключении remap marker:-ссылок при импорте пространства (§7.5);
  5. кэшировании графа по _cfgEpoch (§13).

14. Документация и changelog

Обновить:

  • docs/specs/067-light-source-controls.md — ссылка на superseding rule #84;
  • docs/specs/050-config-export-import.md — §9.1 (см. §7.5);
  • docs/USER-GUIDE.ru.md — рецепт «тупая лампа + умный выключатель», включая честное предупреждение из §5.1; английская версия — раздел в README.md (отдельного EN-гайда в проекте нет);
  • docs/ARCHITECTURE.md — source/state/service identity;
  • docs/CONFIG-COMPATIBILITY.md — marker:* controls refs и downgrade;
  • docs/LIGHT.md — canonical passive-source semantics;
  • RU/EN changelog значимой пользовательской фичей, со ссылкой на #84.

15. Вне scope

  • Ручной on/off state для virtual source.
  • AND/NOT/пороговые выражения вместо OR.
  • Передача live color/brightness controller → passive fixture.
  • Автосоздание fixtures по HA relations.
  • Отдельный drag-and-drop редактор связей.
  • Изменение clip/spill/door/window geometry Glow.