54 KiB
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 порядок такой:
- явные внешние
controls— они намеренно не дают свечения (castsGlow = via !== 'controls',devices.ts:323), только голос в статистике комнаты; marker.is_light === true→ принудительная сущность черезforcedLightEntityOf(devices.ts:274), которая берёт собственную controllable-сущность маркера;- автоопределение: только если функциональная роль маркера —
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+ конечныйbri0.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 Блок из трёх контролов
Подряд, в этом порядке:
- «Является источником света» — радиокнопки Авто / Всегда / Никогда.
- «Цвет и яркость» — три радиокнопки:
- Из источника — цвет и яркость от устройства; при их отсутствии цвет из общих настроек, яркость 100%;
- Задать цвет — колор-пикер; яркость остаётся живой;
- Задать цвет и яркость — колор-пикер плюс шкала яркости 1…100%.
- «Радиус свечения» — существующий контрол без изменений.
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):
- Перенос групповой
opacityв альфу пула сам по себе пиксельно нейтрален для непересекающегося пятна при той же рассчитанной альфе; меняются только зоны перекрытия — раньше группа гасилась после композиции, теперь блендятся уже приглушённые пятна. - Новая перцептивная кривая отдельно и намеренно меняет все диммированные пятна, включая одиночные: при 20% альфа идёт с 0.119 на 0.410 (§5.2).
Поэтому пиксельная эквивалентность обещана только одиночному пятну при 100% яркости и неизменных настройках. Принято владельцем 2026-08-10, golden переснимается.
5.5 Ownership при нескольких сущностях и маркерах
glow_colorприменяется к маркеру, который владеет конкретным пулом (source.device.marker), а не к контроллеру, лишь перечислившему сущность вcontrols.- Если одно устройство даёт несколько включённых
light.*, пул остаётся один; авто-цвет и авто-яркость берутся у первого включённого кандидата в существующем стабильном порядке, ручной override маркера перекрывает этот выбор. - При смене активной сущности внутри такого устройства авто-режим меняется живьём, ручной остаётся стабильным.
- Дедупликация 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 и план разошлись бы, и чужой выключатель окрасил бы
собственное пятно маркера.
Нормативный алгоритм:
- оставить только spatial-кандидаты, то есть
castsGlow === true(внешниеcontrolsотбрасываются целиком); - взять первый включённый в стабильном порядке;
- если включённых нет — взять первого spatial-кандидата (для превью и snapshot);
- если 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. Критерии приёмки
- Роль по умолчанию «Авто»;
light.*-маркер в «Авто» светит ровно как сегодня. - «Никогда» удаляет собственный кандидат маркера. У маркера без
controlsон исчезает из свечения, состояния комнаты и счётчика; у маркера сcontrolsвнешние цели продолжают голосовать — по матрице §3.1. План обновляется без перезагрузки. - «Всегда» на не-световом маркере с подходящей собственной сущностью ведёт себя как сегодняшний
is_light: true; без такой сущности блок цвета и радиуса disabled с подсказкой (§4.4). - Эвристика «телевизор со служебной
light.*» в «Авто» не изменилась и переопределяется. - Расшифровка у «Авто» совпадает с фактическим поведением плана для: лампы, умного выключателя,
телевизора со служебной сущностью, маркера с
controls, выключенной, недоступной, скрытой и HA-disabled сущности. - «Из источника» воспроизводит сегодняшний резолв; фолбэки цвета и яркости независимы.
- «Задать цвет» применяет цвет ко всему свету источника, яркость продолжает следовать источнику.
- «Задать цвет и яркость» фиксирует оба параметра; пятно исчезает при выключении источника; минимум шкалы 1%.
- Любой невалидный
glow_colorцеликом игнорируется, маркер работает в «Авто»; невалидный цвет никогда не доходит до SVG. - Альфа при
bri= 1/255, 0.01, 0.1, 0.2, 0.5, 0.8, 1.0 совпадает с §5.2 (±0.001). - При 100% яркости и дефолтных настройках одиночное пятно пиксельно эквивалентно прежнему.
- На каноническом валидном фикстуре Open → Save без касания новых контролов не добавляет
отсутствующие
is_lightиglow_colorи сохраняет уже существующие валидные значения; прочая канонизация marker writer-а в этот критерий не входит. houseplan-space-cardне расходится в резолве роли;glow_colorдля неё N/A.- Save при задизейбленном блоке не стирает
glow_color; после возврата роли/привязки override снова действует. {c, bri: null}показывается как «Задать цвет», а следующий Save канонизирует объект в{c}.- Цвет, яркость, радиус и snapshot никогда не берутся у внешнего
controls: при «Всегда + включённый control + выключенный собственный источник» snapshot берётся у собственного. - Прямой переход «Из источника» → «Задать цвет и яркость» даёт тот же 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; массово затронутых конфигов не ожидается.