Files
houseplan-card/docs/specs/067-light-source-controls.md
T

54 KiB
Raw Blame History

067 — Источники света: роль, цвет, яркость и интенсивность свечения (ревизия 5)

Правило «Всегда без собственной controllable-сущности = нет источника» из этой ревизии отменено issue #84. Актуальная семантика passive forced source, marker-связей и выбора ведущей сущности (#88) зафиксирована в 084-passive-forced-light-sources.md и каноническом ../LIGHT.md. Остальные решения этого ТЗ продолжают действовать.

  • Issues: #67 (интенсивность, хаб), #65 (роль источника), #66 (цвет и яркость источника)
  • Приоритет: P2
  • Статус ТЗ: ревизия 5 — все замечания ревью раундов 1–4 закрыты, READY FOR IMPLEMENTATION
  • Связано: #19 (аддитивное смешивание пятен), #55 (независимый Glow), #21 (контракт цвета), #33 (реестр совместимости)
  • Итоговое место файла в репозитории: docs/specs/067-light-source-controls.md

Актуализация 2026-08-11 (после #71). Формула яркости и все её константы в силе без изменений: glowAlpha по-прежнему единственный источник альфы. Но эта альфа теперь — значение в ЦЕНТРЕ пятна, а GLOW_FALLOFF расходует её по всему радиусу вместо плато до 70%. Отдельного «пролива» больше не существует: свет за проёмом — то же самое поле источника, поэтому цвет и яркость там совпадают с комнатой источника по построению, а не по совпадению констант.

Три issue делаются вместе, потому что меняют одну цепочку данных marker config → resolvedLightSources → spatial source → цвет/яркость → alpha → Glow pool и один блок диалога маркера. Релиз один: половинчатая поставка даёт промежуточные, необъяснимые состояния UI.

0. Ответ на ревью раундов 1–4

Раунд 4

# Вердикт Решение
R4-B1 Number(null) → 0 Принято — до Number(...) добавлена проверка типа и непустой строки; null, empty/whitespace и boolean дают фолбэк 100%, конечная числовая строка сохраняет совместимость (§3.3)
R4-E1 статус Project Закрыто — #65/#66/#67 находятся в House Plan Backlog, Todo, P2 (§10)
R4-E2 терминология #66 Закрыто — канонический issue использует «Цвет и яркость» и brightness, ссылка обновлена на ревизию 5

Раунд 3

# Вердикт Решение
R3-B1 процесс Принято — из #65 удалена фраза Owner decision pending, решение записано тем же текстом, что в §6/§10; ревизия положена в docs/specs/067-light-source-controls.md, из всех трёх issue дана ссылка; Project синхронизирован (§10)
R3-E1 controls в выборе сущности Принято, замечание точное — порядок кандидатов действительно controls → forced → auto (devices.ts:271-283), поэтому «первая включённая» могла оказаться внешним контролом. Введён нормативный selectSpatialGlowSource с фильтром castsGlow до выбора (§5.6)
R3-E2 прямой переход в режим 3 Принято — любой вход в режим 3 без draft снимает snapshot и цвета, и яркости; последовательный и прямой пути дают одинаковый результат (§4.7)
R3-E3 нормализация живой яркости Принято — нормативная формула вынесена отдельно; отмечено, что это меняет сегодняшнее поведение при brightness = 0 (§3.3)

Раунд 2

# Вердикт Решение
R2-B1 legacy is_light:false Закрыто решением владельца 2026-08-10 — старое false осознанно читается как «Никогда»; пометки «на подтверждении» сняты (§6, §10)
R2-B2 issue расходятся с ТЗ Принято — критерии и тесты #65 переписаны условно по матрице §3.1, таблица и раздел следствий #67 исправлены (1% против raw 1/255; кривая меняет и одиночные пятна). Про Project — §10
R2-B3 resolver против инициализации Принято — уровни разделены: resolveGlowValues (без on/off, для диалога) и resolveGlowAppearance (null при неактивном источнике); задан общий стабильный выбор сущности для snapshot (§5.6)
R2-E1 §5.4 обещает лишнее Принято — два эффекта разведены: перенос групповой opacity пиксельно нейтрален для непересекающегося пятна при той же альфе, кривая отдельно меняет все диммированные
R2-E2 {c, bri: null} Принято — null эквивалентен отсутствию поля: режим 2, следующий Save канонизирует объект в {c}
R2-E3 dormant override Принято — disabled не стирает glow_color при Save; очистка только явным выбором «Из источника» (§4.7)
R2-E4 перф-политика Принято — раздел приведён к уже принятой политике #69

Раунд 1

# Вердикт Решение
B1 таблица неверна для 1% Принято, ошибка признана — пересчитал: alpha(0.01) = 0.2820, а 0.267 относится к HA raw brightness=1 (bri = 1/255 = 0.392%). Строки разделены, столбец контраста пересчитан (§5.2), оба значения в тестах (§8)
B2 «Никогда» vs controls Принято — матрица роль × controls внесена в нормативную часть (§3.1), критерии и смоки переформулированы условно; issue #65 исправлен
B3 legacy is_light:false Принято, «миграций нет» было неверно — выбран вариант 1: старое false осознанно активируется как «Никогда», это read-semantics change в реестр #33 и CHANGELOG (§6). Подтверждено владельцем 2026-08-10
B4 валидация bri пропускает NaN Принято — схема через _finite (validation.py:53), добавлено правило «невалидный override целиком → Авто, без частичного применения» (§3.2)
B5 инициализация ручных значений Принято — snapshot текущего effective-вида, нормализация в #RRGGBB, draft-память диалога (§4.7)
B6 «Всегда» без собственной сущности Принято — вводится общий pure-предикат hasOwnSpatialSource, гейтинг §4.4 расширен третьим случаем
E1 смысл подписи «Авто» Принято — классификация источника, а не его состояние; off/unavailable/hidden/HA-disabled определены (§4.2)
E2 ownership при нескольких сущностях Принято — четыре правила в §5.5 + два юнита
E3 «побайтно» слишком широко Принято — критерий сужен до двух новых полей на каноническом фикстуре; downgrade-риск описан честно (§6)
E4 следы прежней модели opacity Принято — §4.6 (три опции), убрана «непрозрачность при полной яркости», тест переписан на слот bri
E5 единый pure resolver Принято — нормативные resolveGlowAppearance и glowAlpha (§5.6)
E6 static card Принято — role parity обязательна, glow_color для неё N/A (§5.7)
E7 a11y-контракт Принято — §4.8
T1–T4 Приняты — golden-список, переписывание blend-смока, негативные кейсы, политика перф-джоба (§8)
Backlog Принято — #65/#66/#67 приведены в соответствие; членство в Project — задача владельца (§10)

1. Что есть сейчас (проверенные факты)

Разрешение источников. resolvedLightSources (src/devices.ts:311) — единственный источник истины для всех световых функций комнаты: свечение, состояние комнаты on/off/none (devices.ts:340) и счётчик «N из M» (devices.ts:999). Внутри lightEntitiesOf порядок такой:

  1. явные внешние controls — они намеренно не дают свечения (castsGlow = via !== 'controls', devices.ts:323), только голос в статистике комнаты;
  2. marker.is_light === true → принудительная сущность через forcedLightEntityOf (devices.ts:274), которая берёт собственную controllable-сущность маркера;
  3. автоопределение: только если функциональная роль маркера — light.*, берутся его light.* сущности. Телевизор со служебной light.* (подсветка логотипа) сюда намеренно не попадает.

Цвет и альфа пятна. glowColorOf (src/logic.ts:1643): rgb_color → кельвины (color_temp_kelvin/color_temp) → общий цвет fill_colors.glow_light.c; яркость bri = clamp(brightness/255, 0.15, 1) (logic.ts:1647). Альфа стопов градиента: colors.glow_light.a * glow.bri (houseplan-card.ts:11253). Поверх всей группы пятен висит opacity="0.7" (:11289). Дефолт токена — glow_light: { c: '#ffd9a0', a: 0.85 } (logic.ts:1385).

Диалог маркера. Один чекбокс «Источник света» (houseplan-card.ts:14602-14605), читается как marker.is_light === true (:9551), пишется как is_light: dlg.isLight ? true : null (:9946, :14347). Сразу под ним — «Радиус свечения» (:14606-14614).

Хранение. is_light?: boolean | null уже есть (src/types.ts:128), бэкенд принимает vol.Any(bool, None) (validation.py:646). Прецедент per-marker цвета — ripple_color (validation.py:653), пишется только когда осмысленно (houseplan-card.ts:9937). Для конечных чисел в схеме есть _finite (validation.py:53, аудит B5).

2. Проблемы, которые закрываем

#65. Контрол назван как утверждение о факте, а работает как принудительное переопределение: у настоящей лампы свечение приходит из ветки 3, флаг остаётся false. Запретить свечение реальной light.* нельзя вовсе, хотя потребность в коде уже признана эвристикой про телевизор.

#66. Цвет резолвится с одним глобальным фолбэком, яркость и альфа — только глобальные. Любой «бесцветный» источник (лампа за умным выключателем — ровно случай is_light: true) светит одинаково на весь дом; насыщенная RGB-лента наоборот заливает комнату собой. Радиус при этом уже per-marker — модель асимметрична.

#67. Итоговая альфа — произведение трёх множителей, из которых пользователь видит один. Замер: при 20% яркости альфа 0.119, пятно ярче затемнённого пола на 19%.


3. Нормативная модель

3.1 Роль источника (#65)

marker.is_light?: boolean | null — трёхзначное:

Значение UI Смысл
null / отсутствует Авто (дефолт) решает эвристика роли, как сегодня
true Всегда принудительный источник из собственной controllable-сущности
false Никогда собственный источник маркера подавлен

Матрица роль × controls (нормативная). «Никогда» подавляет собственный кандидат маркера и только его; явный список controls — отдельный механизм, управляемый пользователем напрямую:

Роль Внешние controls Результат
Авто нет собственный via:light, если роль маркера — light
Всегда нет собственный via:forced, если есть подходящая controllable-сущность
Никогда нет источников от маркера нет
Авто есть только controls (собственный auto-кандидат подавлен текущим приоритетом)
Всегда есть controls + собственный via:forced
Никогда есть только controls — они продолжают голосовать в on/off/none и «N из M»

Отсюда условная формулировка вместо прежнего безусловного обещания: «Никогда» удаляет собственный кандидат маркера; агрегаты комнаты падают только если у маркера нет controls и в комнате не осталось других источников.

false не меняет: тап-действие, иконку, бейдж, цвет маркера, участие в прочей статистике комнаты (температура, влажность, LQI), дедупликацию ownership между контроллером и физическим маркером — только собственную световую роль.

3.2 Цвет и яркость источника (#66)

marker.glow_color?: { c: string; bri?: number | null } | null. Режим выводится из наличия полей, отдельного перечисления нет:

Хранится Режим UI Цвет Яркость
поля нет Из источника (дефолт) rgb_color → кельвины → glow_light.c brightness → 100%, если не отдаёт
{c} Задать цвет c brightness → 100%, если не отдаёт
{c, bri} с конечным bri Задать цвет и яркость c фиксированная bri
{c, bri: null} Задать цвет (как {c}) c brightness → 100%, если не отдаёт

Backend-схема (нормативно):

vol.Optional("glow_color"): vol.Any(
    None,
    vol.Schema({
        vol.Required("c"): _COLOR,
        vol.Optional("bri"): vol.Any(None, vol.All(_finite, vol.Range(min=0.01, max=1.0))),
    }),
),

_finite обязателен: vol.Coerce(float) + Range пропускает NaN — этот класс уже закрыт аудитом B5 в этом же файле.

Runtime-деградация — всё или ничего:

  • поле отсутствует или null → Авто;
  • валидный c, bri отсутствует или null → ручной цвет, живая яркость (режим 2); null нормативно эквивалентен отсутствию поля, и следующий Save канонизирует объект в {c};
  • валидный c + конечный bri 0.01…1 → ручной цвет и ручная яркость;
  • невалидный c, неконечный или выходящий за диапазон bri, bri без c, массив, строка, посторонние вложенные ключи → весь override невалиден, используется Авто; частичное применение запрещено.

bri хранится как 0.01…1, UI показывает проценты — тем же приёмом, что glow_radius_cm хранит сантиметры при метрах/футах в интерфейсе. Нуль отвергается схемой: шкала начинается с 1% (§4.3).

На рендер-границе сохранённый цвет проходит строгий safeStoredColor (#RRGGBB), а не широкий CSS-парсер. Автоматический rgb(...), сгенерированный из кельвинов, остаётся внутренним значением и идёт своим генератором.

UI в режиме «Из источника» удаляет поле, а не пишет glow_color: null.

Плата за выведение режима из данных, принятая осознанно: возврат в «Из источника» стирает ручные значения из конфига; в пределах открытого диалога они помнятся (§4.7).

Ручное значение подменяет яркость, а не потолок альфы: A_max всегда общий. Per-marker непрозрачности в модели нет.

3.3 Интенсивность (#67)

const GLOW_SCALE_MAX = 0.7;     // потолок шкалы: 100% интенсивности == фактические 0.7
const GLOW_MIN_FRAC  = 0.4;     // доля потолка у еле включённой лампы
const GLOW_GAMMA     = 1 / 2.2; // перцептивная кривая (sRGB-гамма)
A_max = fill_colors.glow_light.a × GLOW_SCALE_MAX        // потолок ОБЩИЙ, не per-marker
bri   = marker.glow_color?.bri ?? clamp(brightness / 255, 0, 1)   // БЕЗ прежнего зажима 0.15
alpha = A_max × ( GLOW_MIN_FRAC + (1 − GLOW_MIN_FRAC) × bri^GLOW_GAMMA )

Нормализация живой яркости (нормативно):

const attr = state?.attributes?.brightness;
const raw = typeof attr === 'number'
  ? attr
  : (typeof attr === 'string' && attr.trim() !== '' ? Number(attr) : Number.NaN);
const bri = Number.isFinite(raw) ? clamp(raw / 255, 0, 1) : 1;

Терпимость к конечной числовой строке у HA-атрибута сохраняется (Number('128')), но null, empty/whitespace и boolean считаются отсутствующим/невалидным значением и дают bri = 1. Правила строгой валидации glow_color.bri этим не затрагиваются. Лампа без атрибута — bri = 1.

Это меняет сегодняшнее поведение в одном месте: сейчас briRaw > 0 в условии (logic.ts:1647) отправляет brightness = 0, отрицательное и NaN в ветку «полная яркость», то есть включённая лампа с brightness: 0 светит сегодня на 100%. По новой формуле ноль и отрицательное дают нижний пол, NaN/отсутствие — по-прежнему 100%. Полагаться на то, что ноль встречается только при state = off, нельзя.

Зажим Math.max(0.15, …) удаляется — иначе двойной пол. Пол выражен долей потолка, а не абсолютом.

opacity="0.7" с группы пятен (houseplan-card.ts:11289) удаляется; isolation: isolate (styles.ts:508) остаётся и объявлен независимо, поэтому изоляция screen-блендинга не ломается. Устаревший комментарий в styles.ts про «outer opacity применяется после композиции» обновить.


4. Нормативный UI

4.1 Блок из трёх контролов

Подряд, в этом порядке:

  1. «Является источником света» — радиокнопки Авто / Всегда / Никогда.
  2. «Цвет и яркость» — три радиокнопки:
    • Из источника — цвет и яркость от устройства; при их отсутствии цвет из общих настроек, яркость 100%;
    • Задать цвет — колор-пикер; яркость остаётся живой;
    • Задать цвет и яркость — колор-пикер плюс шкала яркости 1…100%.
  3. «Радиус свечения» — существующий контрол без изменений.

4.2 Расшифровка у «Авто»

Авто (является источником) / Авто (не является источником) — две полные строки перевода, без конкатенации.

Подпись описывает классификацию собственного spatial-источника, а не его текущее состояние:

  • off и unavailable не меняют текст: выключенная лампа остаётся источником света;
  • временно скрытый маркер (hidden) тоже остаётся «является» — скрытие это отдельная ось, подпись объясняет, что произойдёт после показа;
  • HA-disabled binding не имеет runtime-сущности → «не является», вместе с уже существующим баннером; после включения в HA пересчитывается;
  • несохранённая привязка и правки controls в этом же диалоге учитываются живьём;
  • значение берётся из общего helper-а резолвера, никогда из проверки домена в UI.

4.3 Ручная шкала яркости

Проценты — аналог значения устройства: попадают в слот bri формулы §3.3 и проходят кривую. Это не сырая непрозрачность.

Шкала 1…100%, нуля нет: формула на нуле даёт пол A_max × GLOW_MIN_FRAC ≈ 0.238 — заметное пятно, и «0%» читалось бы как «выключить». Выключение — роль «Никогда» строкой выше.

Режим «Задать цвет и яркость» игнорирует живую яркость: лампа, приколотая к 40%, светит на 40% независимо от реального диммирования. Включение/выключение продолжает действовать.

4.4 Гейтинг

Вводится общий pure-предикат hasOwnSpatialSource(hass, device) — экспортируется из devices.ts и используется и внутри lightEntitiesOf, и диалогом. Копирование эвристики в карточку запрещено.

Состояние Контролы «Цвет и яркость» и «Радиус»
Авто (является) или Всегда с собственной controllable-сущностью активны
Авто (не является) disabled, подсказка указывает на контрол выше
Никогда disabled, подсказка указывает на контрол выше
Всегда без собственной controllable-сущности disabled, подсказка «У привязки нет сущности, состоянием которой можно управлять свечением»

Наличие controls само по себе блок не активирует: внешние цели намеренно не spatial. Смена привязки в этом же диалоге пересчитывает состояние живьём. Контролы не прячутся — только дизейблятся: прятанье и породило исходную жалобу #65.

4.5 Отсутствие молчаливой материализации

Открытие и сохранение диалога без касания новых контролов не добавляет в конфиг ни is_light, ни glow_color (уточнённая формулировка — §7, критерий 12).

4.6 i18n

Ключи в обеих локалях: подпись и подсказка блока роли, три подписи опций роли (две из них с расшифровкой), подпись и подсказка блока «Цвет и яркость», три подписи его опций, подпись шкалы яркости, подсказка про 1% и про выключение через «Никогда», подсказки трёх disabled-случаев §4.4. Существующие marker.is_light / marker.is_light_tip переформулировать. Конкатенация подписей запрещена (§17 HP-UX-11).

4.7 Инициализация ручных значений и draft-память

  • Из источника → Задать цвет: пикер получает snapshot текущего effective auto-цвета, нормализованный в #RRGGBB (автоматический rgb(...)/кельвины конвертируются); если источник цвета не даёт — общий glow_light.c.
  • Задать цвет → Задать цвет и яркость: шкала получает текущую конечную brightness/255, округлённую до целого процента и зажатую в 1…100; при отсутствии или невалидности — 100%.
  • Выключенная/недоступная лампа: атрибуты используются, если они есть; иначе фолбэк выше.
  • Snapshot берётся из resolveGlowValues (§5.6), а не из собственной копии цепочки в диалоге; сущность выбирается общим selectSpatialGlowSource — внешние controls в выбор не попадают.
  • Прямой переход «Из источника» → «Задать цвет и яркость» (радиокнопки позволяют выбрать третий режим сразу) при отсутствии draft снимает snapshot и цвета, и яркости по тем же правилам. Последовательный и прямой пути обязаны давать одинаковый draft.
  • Последние ручные значения хранятся как draft диалога и переживают любые переключения режимов до закрытия. Cancel не пишет ничего.
  • Dormant override. Если блок задизейблен (роль «Никогда», «Авто (не является)» или «Всегда» без собственной сущности), уже существующий или только что заданный glow_color при Save сохраняется, а не стирается: он снова начнёт действовать, когда роль или привязка вернут маркеру световую роль. Очистка — только явным выбором «Из источника».

4.8 Доступность

Оба набора радиокнопок — fieldset + legend либо эквивалентный role="radiogroup" с accessible name. Disabled-состояние — настоящие disabled-атрибуты плюс видимая подсказка и aria-describedby, а не только title. Шкала яркости: шаг 1%, управление стрелками, видимое числовое значение, локализованное доступное имя. Динамическая расшифровка у «Авто» не перехватывает фокус и не объявляется скринридером на каждом тике состояния HA — только при изменении факторов классификации. Мобильный диалог не должен получать горизонтальный скролл или обрезанный футер.


5. Нормативный рендер

5.1 Ручные значения и формула

Ручной цвет заменяет всю цепочку резолва цвета; ручная яркость заменяет brightness устройства в слоте bri. Потолок A_max всегда общий. В режиме «Задать цвет» пятно продолжает следовать живой яркости источника.

5.2 Контрольная таблица (дефолтный токен 0.85 → A_max = 0.595)

Яркость сейчас по формуле светлее пола
HA raw brightness = 1 (0.392%) 0.089 0.267 1.42×
1% на ручной шкале — 0.282 1.44×
10% 0.090 0.363 1.57×
20% 0.119 0.410 1.64×
50% 0.299 0.499 1.78×
80% 0.476 0.561 1.87×
100% 0.595 0.595 1.93× (не меняется)

Теоретический bri = 0 дал бы 0.238; на ручной шкале он недостижим, на живой — только у выключенной лампы, у которой пятна нет вовсе. «Светлее пола» — отношение яркости центра пятна к затемнённому полу (paper → glow_base 0.78 → L ≈ 81) для тёплого #ffc882. Числа приёмочные: альфа воспроизводится с точностью до 0.001.

5.3 Свет через проёмы

Пролив использует тот же разрешённый цвет и ту же альфу, что и пятно-источник.

5.4 Перекрытия пятен — ожидаемое изменение

Здесь работают два независимых эффекта, и их нельзя путать (замечание R2-E1):

  1. Перенос групповой opacity в альфу пула сам по себе пиксельно нейтрален для непересекающегося пятна при той же рассчитанной альфе; меняются только зоны перекрытия — раньше группа гасилась после композиции, теперь блендятся уже приглушённые пятна.
  2. Новая перцептивная кривая отдельно и намеренно меняет все диммированные пятна, включая одиночные: при 20% альфа идёт с 0.119 на 0.410 (§5.2).

Поэтому пиксельная эквивалентность обещана только одиночному пятну при 100% яркости и неизменных настройках. Принято владельцем 2026-08-10, golden переснимается.

5.5 Ownership при нескольких сущностях и маркерах

  1. glow_color применяется к маркеру, который владеет конкретным пулом (source.device.marker), а не к контроллеру, лишь перечислившему сущность в controls.
  2. Если одно устройство даёт несколько включённых light.*, пул остаётся один; авто-цвет и авто-яркость берутся у первого включённого кандидата в существующем стабильном порядке, ручной override маркера перекрывает этот выбор.
  3. При смене активной сущности внутри такого устройства авто-режим меняется живьём, ручной остаётся стабильным.
  4. Дедупликация room stats и передача ownership физическому маркеру этим скоупом не меняются.

5.6 Единый pure-резолвер

Рядом с glowColorOf объявляются две чистые функции, и только они содержат логику:

resolveGlowValues(state, markerGlowColor, fallbackColor): { c: string; bri: number }
resolveGlowAppearance(state, markerGlowColor, fallbackColor): { c: string; bri: number } | null
glowAlpha(brightnessFraction: number, paletteAlpha: number): number

Три уровня, разведённые намеренно (замечание R2-B3):

  • resolveGlowValues игнорирует on/off и всегда возвращает значения: ручной или автоматический цвет (RGB → кельвины → фолбэк, нормализованный в #RRGGBB) и ручную или живую яркость. Это то, что берёт диалог для snapshot при первом переходе в ручной режим (§4.7) — иначе ему пришлось бы копировать цепочку, ровно то расхождение, которое ТЗ запрещает.
  • resolveGlowAppearance возвращает null, если источник неактивен, иначе делегирует первому. Это то, что берёт рендер.
  • glowAlpha содержит GLOW_SCALE_MAX, GLOW_MIN_FRAC, GLOW_GAMMA, clamp и больше ничего.

Общий выбор сущности — selectSpatialGlowSource(sources): ResolvedLightSource | null. Отдельный pure-helper уровня источников, потому что порядок кандидатов в lightEntitiesOf — controls → forced → auto (devices.ts:271-283), и «первая включённая» без фильтра вполне может оказаться внешним контролом: рендер игнорирует его через castsGlow = false, а буквальный snapshot взял бы его цвет и яркость — UI и план разошлись бы, и чужой выключатель окрасил бы собственное пятно маркера.

Нормативный алгоритм:

  1. оставить только spatial-кандидаты, то есть castsGlow === true (внешние controls отбрасываются целиком);
  2. взять первый включённый в стабильном порядке;
  3. если включённых нет — взять первого spatial-кандидата (для превью и snapshot);
  4. если spatial-кандидатов нет — вернуть null.

Рендер, превью диалога и инициализация draft обязаны пользоваться этим helper-ом. Внешние controls участвуют в статистике комнаты, но никогда не являются источником цвета, яркости, радиуса или snapshot.

SVG-слой получает готовые {c, alpha} — так формула не разойдётся между рендером и тестами.

5.7 Статическая карточка

Паритет роли обязателен: resolvedLightSources влияет на заливку и состояние комнаты. glow_color для houseplan-space-card сейчас N/A — она не рисует пулы, и вторую неиспользуемую реализацию заводить нельзя. Если она когда-нибудь начнёт рисовать пулы, обязана подключить тот же resolveGlowAppearance.


6. Совместимость

Данных для миграции нет, но есть изменение семантики чтения. Сегодня проверяется только is_light === true (devices.ts:274), поэтому сохранённое is_light: false фактически означает «Авто». После этой фичи оно начнёт означать «Никогда».

Решение владельца от 2026-08-10 (вариант 1 ревью): активировать естественную семантику осознанно. Исторический writer писал только true или null, поэтому затронуты почти исключительно ручные и YAML-конфиги. Записать в реестр совместимости #33 и отдельной строкой в CHANGELOG; тест начинается с legacy-конфига {is_light: false}.

Downgrade / stale tab. Старый фронтенд неизвестное glow_color отрисует без падения, но при открытии и сохранении этого маркера может удалить поле, потому что старый writer реконструирует объект. Новой версией задним числом это не чинится — риск описывается честно, а не прикрывается формулировкой «не ломает рендер».

Визуальные изменения по умолчанию: сцены с диммированным светом и зоны перекрытия пятен. При полной яркости и одиночном пятне — пиксельная эквивалентность (не побайтная: SVG-разметка заведомо меняется из-за удаления opacity="0.7").

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

  1. Роль по умолчанию «Авто»; light.*-маркер в «Авто» светит ровно как сегодня.
  2. «Никогда» удаляет собственный кандидат маркера. У маркера без controls он исчезает из свечения, состояния комнаты и счётчика; у маркера с controls внешние цели продолжают голосовать — по матрице §3.1. План обновляется без перезагрузки.
  3. «Всегда» на не-световом маркере с подходящей собственной сущностью ведёт себя как сегодняшний is_light: true; без такой сущности блок цвета и радиуса disabled с подсказкой (§4.4).
  4. Эвристика «телевизор со служебной light.*» в «Авто» не изменилась и переопределяется.
  5. Расшифровка у «Авто» совпадает с фактическим поведением плана для: лампы, умного выключателя, телевизора со служебной сущностью, маркера с controls, выключенной, недоступной, скрытой и HA-disabled сущности.
  6. «Из источника» воспроизводит сегодняшний резолв; фолбэки цвета и яркости независимы.
  7. «Задать цвет» применяет цвет ко всему свету источника, яркость продолжает следовать источнику.
  8. «Задать цвет и яркость» фиксирует оба параметра; пятно исчезает при выключении источника; минимум шкалы 1%.
  9. Любой невалидный glow_color целиком игнорируется, маркер работает в «Авто»; невалидный цвет никогда не доходит до SVG.
  10. Альфа при bri = 1/255, 0.01, 0.1, 0.2, 0.5, 0.8, 1.0 совпадает с §5.2 (±0.001).
  11. При 100% яркости и дефолтных настройках одиночное пятно пиксельно эквивалентно прежнему.
  12. На каноническом валидном фикстуре Open → Save без касания новых контролов не добавляет отсутствующие is_light и glow_color и сохраняет уже существующие валидные значения; прочая канонизация marker writer-а в этот критерий не входит.
  13. houseplan-space-card не расходится в резолве роли; glow_color для неё N/A.
  14. Save при задизейбленном блоке не стирает glow_color; после возврата роли/привязки override снова действует.
  15. {c, bri: null} показывается как «Задать цвет», а следующий Save канонизирует объект в {c}.
  16. Цвет, яркость, радиус и snapshot никогда не берутся у внешнего controls: при «Всегда + включённый control + выключенный собственный источник» snapshot берётся у собственного.
  17. Прямой переход «Из источника» → «Задать цвет и яркость» даёт тот же draft, что и через режим 2.

8. Тесты

Юниты.

  • lightEntitiesOf/resolvedLightSources: три состояния роли × наличие controls — все шесть строк матрицы §3.1 с точными ожидаемыми результатами.
  • Legacy-конфиг {is_light: false} читается как «Никогда».
  • «Всегда» на virtual/sensor.*-привязке без controllable-сущности: источник не создаётся, hasOwnSpatialSource = false.
  • Подпись «Авто» для off / unavailable / hidden / HA-disabled.
  • Ownership: контроллер + физическая лампа с разными glow_color; устройство с двумя включёнными light.* и сменой активной сущности.
  • resolveGlowAppearance: три режима × (RGB, кельвины, бесцветный, с яркостью и без); невалидные c; {c, bri: null}; bri без c; NaN; Infinity; числовая строка; массив; посторонние вложенные ключи — во всех случаях полный откат в Авто.
  • glowAlpha: таблица §5.2 включая обе нижние точки (1/255 и 0.01); монотонность; glowAlpha(1, a) === a × GLOW_SCALE_MAX; отсутствие двойного пола.
  • Нормализация живой яркости: numeric 0, отрицательное, > 255, NaN, Infinity, отсутствие атрибута, null, empty/whitespace, boolean и конечная числовая строка — включая явную фиксацию изменения поведения при brightness = 0; null/empty/boolean дают 1, '128' остаётся числом.
  • selectSpatialGlowSource: «Всегда + включённый внешний control + выключенный собственный forced» → выбран собственный; устройство без spatial-кандидатов → null; порядок при нескольких включённых стабилен.
  • Прямой переход из режима 1 в режим 3 при RGB-, kelvin- и бесцветном источнике даёт тот же draft, что и последовательный.

Смоки.

  • Авто → Никогда → Авто: пятно исчезает и возвращается; для маркера с controls счётчик комнаты ведёт себя по матрице.
  • Ручной цвет: стопы градиента маркера несут выбранный цвет, соседняя авто-лампа — свой; свет за проёмом того же цвета, что и в комнате источника (это одно и то же поле).
  • Первое переключение в ручной режим подставляет snapshot (§4.7) и не дёргает план.
  • Dormant override: ручной цвет → роль «Никогда» → Save → reopen: значение на месте и снова применяется после возврата роли.
  • Draft-память: переключение режимов туда-обратно в открытом диалоге сохраняет значения; после Save в режиме «Из источника» конфиг чист от glow_color; Cancel не пишет ничего.
  • Отсутствие группового opacity на .glow-pools-frame при сохранённом isolation: isolate.
  • Disabled-состояния всех трёх случаев §4.4 с подсказками.

Бэкенд. is_light: false и glow_color round-trip; отвергаются NaN, Infinity, bri: 0, bri вне 0.01…1, невалидный c, лишние ключи.

Golden. Ожидаемо меняются от новой формулы и удаления групповой opacity: lighting-glow-sun-dark, lighting-temp-glow-dark, lighting-temp-glow-light, lighting-custom-glow-dark, lighting-custom-glow-light, lighting-temp-glow-room-override-dark, hover-over-glow-dark. Отдельно, от нового блока диалога: device-dialog-desktop-en и device-dialog-mobile-ru. Сцена lighting-temp-glow-no-sources-dark (весь свет выключен) меняться не должна — дешёвая проверка, что тронули ровно то, что собирались. Новая сцена: авто-лампа + ручная лампа + видимый пролив через проём + перекрытие пятен — одним бейзлайном закрывает #19, #55 и #66. Все бейзлайны утверждает владелец до мержа.

smoke_glow_blending.mjs переписывается, а не ослабляется. Сейчас он буквально строит синтетический растр <g opacity="0.7">…</g> (:61) и сравнивает модель «сначала screen, потом групповая opacity». После изменения он обязан проверять per-pool alpha и новые ожидаемые пиксели перекрытия.

Перф — по уже принятой политике #69. Формула добавляет O(1) арифметику на источник, а удаление группового opacity скорее снижает стоимость композитинга. На каждой бета-итерации достаточно обычного быстрого Validate (frontend, backend, smoke, golden плюс candidate-only Glow-смок на 60 источниках); один ручной Full Performance — на финальном бета-кандидате; перед стабильным релизом полный exact-SHA прогон обязателен и так. Бюджеты не поднимать.

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

docs/USER-GUIDE.ru.md — источники света: три состояния роли, ручной цвет и яркость, смысл 1%. docs/ARCHITECTURE.md — цепочка множителей альфы в одном месте и удаление группового opacity. docs/CONFIG-COMPATIBILITY.md — glow_color, трёхзначность is_light и изменение семантики чтения legacy false в реестр #33. CHANGELOG en/ru — роль источника, свой цвет и яркость, видимое свечение диммированных ламп, отдельной строкой изменение смысла is_light: false.

10. Журнал решений владельца

Дата Решение
2026-08-10 Роль источника: показывать включённым и дать выключать; контрол — Авто/Всегда/Никогда
2026-08-10 У «Авто» — расшифровка в скобках: является / не является источником
2026-08-10 Цвет и яркость разделены на три режима; порядок блока: роль → цвет и яркость → радиус
2026-08-10 Ручная шкала яркости начинается с 1%, нуля нет
2026-08-10 Интенсивность: 0.7 убрать как скрытый множитель, ввести перцептивную кривую, потолок при 100% — фактические 0.7
2026-08-10 Пересъёмка golden с перекрытиями принята как цена изменения
2026-08-10 B3 подтверждён: старое is_light: false начинает означать «Никогда» (read-semantics change)

Процесс: тексты #65, #66 и #67 приведены в соответствие с этой ревизией; из #65 удалена фраза Owner decision pending. Ревизия положена в репозиторий по адресу docs/specs/067-light-source-controls.md, все три issue на неё ссылаются. #65, #66 и #67 добавлены в GitHub Project Matysh/1 (House Plan Backlog), каждому выставлены Status = Todo и Priority = P2; live projectItems перепроверены 2026-08-10.

11. Риски

  • Перекрытия ярче ожидаемого. В комнате с 4–5 лампами суммарная альфа при screen-блендинге может уйти в пересвет. Смотреть на новых бейзлайнах; регулировать GLOW_MIN_FRAC, а не возвращать групповую opacity.
  • GLOW_MIN_FRAC = 0.4 сжимает шкалу. Разница между 10% и 100% — 0.363 против 0.595: диммирование читается слабее, чем сейчас. Осознанный размен; если покажется плоско, снижать до 0.3.
  • Расшифровка «Авто» — второй потребитель резолвера. Реализация «своей» логикой в диалоге воспроизведёт ровно тот дефект, который #65 закрывает. Тест на совпадение подписи с фактическим поведением обязателен.
  • Legacy is_light: false в чужих YAML. Пользователь, который когда-то выставил false вручную, ожидая «Авто», после релиза потеряет свечение маркера. Смягчение — строка в CHANGELOG и реестр #33; массово затронутых конфигов не ожидается.