feat: BindableFloat — universal value source binding for all scalar properties
Lint & Test / test (push) Successful in 1m20s

Introduce BindableFloat abstraction that allows any numeric property to be
either a static value or dynamically driven by a ValueSource. Backward-compatible
serialization: plain float when unbound, {value, source_id} dict when bound.

Backend:
- storage/bindable.py — BindableFloat dataclass + bfloat() helper
- 25+ scalar properties converted across all entity types
- Runtime VS acquisition in ColorStripStreamManager for CSS bindings
- All stream hot loops use self.resolve() for live values
- KeyColorsColorStripStream now inherits ColorStripStream

Frontend:
- BindableScalarWidget (slider + VS picker toggle) for all editors
- TypeScript BindableFloat type + helpers
- Graph editor edges for all bindable properties
- Audio source channel IconSelect grid

Fixes: daylight longitude, candlelight wind_strength/candle_type from_dict
This commit is contained in:
2026-03-29 00:33:24 +03:00
parent 5f70302263
commit 8a17bb5caa
48 changed files with 2512 additions and 887 deletions
@@ -1109,3 +1109,69 @@ textarea:focus-visible {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── BindableScalarWidget ── */
.bindable-slider-row,
.bindable-vs-row {
display: flex;
align-items: center;
gap: 8px;
}
.bindable-slider-row input[type="range"] {
flex: 1;
min-width: 0;
}
.bindable-vs-row select,
.bindable-vs-row .entity-select-btn {
flex: 1;
min-width: 0;
}
.bindable-value {
min-width: 3.5ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
font-size: 0.85rem;
}
.bindable-toggle {
flex-shrink: 0;
width: 26px;
height: 26px;
padding: 3px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
background: var(--bg-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.bindable-toggle:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.bindable-toggle--active {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--bg-color);
}
.bindable-toggle--active:hover {
opacity: 0.85;
color: var(--bg-color);
}
.bindable-toggle .icon-xs {
width: 16px;
height: 16px;
fill: currentColor;
}
@@ -0,0 +1,211 @@
/**
* BindableScalarWidget — a slider that can optionally bind to a value source.
*
* Renders a slider (range input) with a small toggle button. When toggled to
* "bound" mode, shows an EntitySelect value source picker instead of the slider.
* Emits a BindableFloat value: plain number (static) or {value, source_id} (bound).
*
* Usage:
* const widget = new BindableScalarWidget({
* container: document.getElementById('my-container'),
* label: 'Smoothing',
* min: 0, max: 1, step: 0.05, default: 0.3,
* valueSources: () => cachedValueSources,
* onChange: (bf) => { … },
* });
* widget.setValue({ value: 0.3, source_id: 'vs_abc' });
* const bf = widget.getValue(); // BindableFloat
*/
import type { BindableFloat } from '../types.ts';
import { bindableValue, bindableSourceId } from '../types.ts';
import { EntitySelect } from './entity-palette.ts';
import { getValueSourceIcon } from './icons.ts';
import { t } from './i18n.ts';
export interface BindableScalarOpts {
container: HTMLElement;
label?: string;
min: number;
max: number;
step: number;
default: number;
/** Format the display value (default: 2 decimal places) */
format?: (v: number) => string;
valueSources: () => Array<{ id: string; name: string; source_type: string }>;
onChange?: (value: BindableFloat) => void;
/** HTML id prefix for generated elements */
idPrefix?: string;
/** Label for the "no binding" option (default: generic "None (static value)") */
noneLabel?: string;
}
let _widgetCounter = 0;
export class BindableScalarWidget {
private _container: HTMLElement;
private _opts: BindableScalarOpts;
private _id: string;
private _bound: boolean = false;
private _staticValue: number;
private _sourceId: string = '';
private _entitySelect: EntitySelect | null = null;
// DOM elements
private _sliderRow!: HTMLElement;
private _vsRow!: HTMLElement;
private _slider!: HTMLInputElement;
private _display!: HTMLElement;
private _toggleBtn!: HTMLButtonElement;
private _select!: HTMLSelectElement;
constructor(opts: BindableScalarOpts) {
this._opts = opts;
this._container = opts.container;
this._staticValue = opts.default;
this._id = opts.idPrefix || `bsw-${++_widgetCounter}`;
this._render();
}
private _format(v: number): string {
return this._opts.format ? this._opts.format(v) : v.toFixed(2);
}
private _render(): void {
const { min, max, step } = this._opts;
const id = this._id;
// Toggle button (link icon for binding)
const toggleHtml = `<button type="button" class="bindable-toggle" id="${id}-toggle" title="${t('bindable.toggle')}" aria-label="Toggle value source binding">
<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
</button>`;
// Slider row (static mode)
const sliderHtml = `<div class="bindable-slider-row" id="${id}-slider-row">
<input type="range" id="${id}-slider" min="${min}" max="${max}" step="${step}" value="${this._staticValue}">
<span class="bindable-value" id="${id}-display">${this._format(this._staticValue)}</span>
${toggleHtml}
</div>`;
// VS picker row (bound mode)
const vsHtml = `<div class="bindable-vs-row" id="${id}-vs-row" style="display:none">
<select id="${id}-select"></select>
<button type="button" class="bindable-toggle bindable-toggle--active" id="${id}-untoggle" title="${t('bindable.toggle')}" aria-label="Switch to static value">
<svg class="icon icon-xs" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
</button>
</div>`;
this._container.innerHTML = sliderHtml + vsHtml;
// Cache DOM refs
this._sliderRow = document.getElementById(`${id}-slider-row`)!;
this._vsRow = document.getElementById(`${id}-vs-row`)!;
this._slider = document.getElementById(`${id}-slider`) as HTMLInputElement;
this._display = document.getElementById(`${id}-display`)!;
this._select = document.getElementById(`${id}-select`) as HTMLSelectElement;
this._toggleBtn = document.getElementById(`${id}-toggle`) as HTMLButtonElement;
// Slider input handler
this._slider.addEventListener('input', () => {
this._staticValue = parseFloat(this._slider.value);
this._display.textContent = this._format(this._staticValue);
this._fireChange();
});
// Toggle to bound mode
this._toggleBtn.addEventListener('click', () => this._setMode(true));
document.getElementById(`${id}-untoggle`)!.addEventListener('click', () => this._setMode(false));
}
private _setMode(bound: boolean): void {
this._bound = bound;
this._sliderRow.style.display = bound ? 'none' : '';
this._vsRow.style.display = bound ? '' : 'none';
if (bound) {
this._populateVsSelect();
} else {
this._sourceId = '';
if (this._entitySelect) {
this._entitySelect.destroy();
this._entitySelect = null;
}
}
this._fireChange();
}
private _populateVsSelect(): void {
const sources = this._opts.valueSources();
const id = this._id;
this._select.innerHTML = `<option value="">${this._opts.noneLabel || t('bindable.none')}</option>` +
sources.map(vs =>
`<option value="${vs.id}"${vs.id === this._sourceId ? ' selected' : ''}>${vs.name}</option>`
).join('');
// Wrap with EntitySelect
if (this._entitySelect) this._entitySelect.destroy();
this._entitySelect = new EntitySelect({
target: this._select,
getItems: () => sources.map(vs => ({
value: vs.id,
label: vs.name,
icon: getValueSourceIcon(vs.source_type),
desc: vs.source_type,
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: this._opts.noneLabel || t('bindable.none'),
onChange: (value: string) => {
this._sourceId = value;
this._fireChange();
},
});
}
private _fireChange(): void {
if (this._opts.onChange) {
this._opts.onChange(this.getValue());
}
}
// ── Public API ──
getValue(): BindableFloat {
if (this._bound && this._sourceId) {
return { value: this._staticValue, source_id: this._sourceId };
}
return this._staticValue;
}
setValue(bf: BindableFloat | undefined): void {
this._staticValue = bindableValue(bf, this._opts.default);
this._sourceId = bindableSourceId(bf);
this._bound = !!this._sourceId;
this._slider.value = String(this._staticValue);
this._display.textContent = this._format(this._staticValue);
this._sliderRow.style.display = this._bound ? 'none' : '';
this._vsRow.style.display = this._bound ? '' : 'none';
if (this._bound) {
this._populateVsSelect();
}
}
/** Refresh the VS dropdown after cache updates. */
refresh(): void {
if (this._bound) {
this._populateVsSelect();
}
}
destroy(): void {
if (this._entitySelect) {
this._entitySelect.destroy();
this._entitySelect = null;
}
this._container.innerHTML = '';
}
}
@@ -57,20 +57,34 @@ const CONNECTION_MAP: ConnectionEntry[] = [
// Output targets
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache, nested: true },
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
// Automations
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
// ── BindableFloat value source edges (CSS properties) ──
{ targetKind: 'color_strip_source', field: 'smoothing.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'sensitivity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'intensity.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'scale.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'speed.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'wind_strength.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'temperature_influence.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'sound_volume.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'timeout.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// HA light target transition binding
{ targetKind: 'output_target', field: 'transition.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
// ── Nested fields (not drag-editable in V1) ──
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
{ targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
{ targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true },
{ targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true },
{ targetKind: 'output_target', field: 'settings.brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'output_target', field: 'settings.brightness.source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
];
@@ -3,6 +3,7 @@
*/
import ELK from 'elkjs/lib/elk.bundled.js';
import { bindableSourceId } from '../types.ts';
/* ── Types ────────────────────────────────────────────────────── */
@@ -351,18 +352,29 @@ function buildGraph(e: EntitiesInput): { nodes: LayoutNode[]; edges: LayoutEdge[
if (line.picture_source_id) addEdge(line.picture_source_id, s.id, 'calibration.picture_source_id');
}
}
// BindableFloat value source edges
for (const prop of ['smoothing', 'sensitivity', 'intensity', 'scale', 'speed',
'wind_strength', 'temperature_influence', 'sound_volume', 'timeout', 'brightness'] as const) {
const vsId = bindableSourceId((s as any)[prop]);
if (vsId) addEdge(vsId, s.id, `${prop}.source_id`);
}
}
// Output target edges
for (const t of e.outputTargets || []) {
if (t.device_id) addEdge(t.device_id, t.id, 'device_id');
if (t.color_strip_source_id) addEdge(t.color_strip_source_id, t.id, 'color_strip_source_id');
if (t.brightness_value_source_id) addEdge(t.brightness_value_source_id, t.id, 'brightness_value_source_id');
const bvsId = bindableSourceId(t.brightness);
if (bvsId) addEdge(bvsId, t.id, 'brightness.source_id');
const transVsId = bindableSourceId(t.transition);
if (transVsId) addEdge(transVsId, t.id, 'transition.source_id');
if (t.picture_source_id) addEdge(t.picture_source_id, t.id, 'picture_source_id');
// KC target settings
if (t.settings) {
if (t.settings.pattern_template_id) addEdge(t.settings.pattern_template_id, t.id, 'settings.pattern_template_id');
if (t.settings.brightness_value_source_id) addEdge(t.settings.brightness_value_source_id, t.id, 'settings.brightness_value_source_id');
const settingsBvsId = bindableSourceId(t.settings?.brightness);
if (settingsBvsId) addEdge(settingsBvsId, t.id, 'settings.brightness.source_id');
}
}
@@ -91,3 +91,9 @@ export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
export const home = '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>';
export const lock = '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>';
export const check = '<path d="M20 6 9 17l-5-5"/>';
export const code = '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>';
export const doorOpen = '<path d="M13 4h3a2 2 0 0 1 2 2v14"/><path d="M2 20h3"/><path d="M13 20h9"/><path d="M10 12v.01"/><path d="M13 4.562v16.157a1 1 0 0 1-1.242.97L5 20V5.562a2 2 0 0 1 1.515-1.94l4-1A2 2 0 0 1 13 4.561Z"/>';
export const toggleRight = '<rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/>';
export const droplets = '<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z"/><path d="M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97"/>';
export const fan = '<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/>';
@@ -91,6 +91,139 @@ export function getAudioEngineIcon(engineType: string): string {
return _audioEngineTypeIcons[engineType] || _svg(P.music);
}
// ── MDI → Lucide icon mapping (for Home Assistant entities) ──
const _mdiMap: Record<string, string> = {
'mdi:lightbulb': P.lightbulb,
'mdi:lightbulb-group': P.lightbulb,
'mdi:lightbulb-outline': P.lightbulb,
'mdi:led-strip': P.lightbulb,
'mdi:led-strip-variant': P.lightbulb,
'mdi:lamp': P.lightbulb,
'mdi:ceiling-light': P.lightbulb,
'mdi:floor-lamp': P.lightbulb,
'mdi:desk-lamp': P.lightbulb,
'mdi:wall-sconce': P.lightbulb,
'mdi:thermometer': P.thermometer,
'mdi:temperature-celsius': P.thermometer,
'mdi:temperature-fahrenheit': P.thermometer,
'mdi:water-thermometer': P.thermometer,
'mdi:home-thermometer': P.thermometer,
'mdi:monitor': P.monitor,
'mdi:television': P.tv,
'mdi:sun-wireless': P.sun,
'mdi:weather-sunny': P.sun,
'mdi:weather-night': P.moon,
'mdi:moon-waning-crescent': P.moon,
'mdi:lock': P.lock,
'mdi:lock-open': P.lock,
'mdi:wifi': P.wifi,
'mdi:fire': P.flame,
'mdi:power': P.power,
'mdi:power-plug': P.plug,
'mdi:eye': P.eye,
'mdi:home': P.home,
'mdi:home-assistant': P.home,
'mdi:music': P.music,
'mdi:music-note': P.music,
'mdi:camera': P.camera,
'mdi:flash': P.zap,
'mdi:flash-alert': P.zap,
'mdi:bell': P.bellRing,
'mdi:bell-ring': P.bellRing,
'mdi:earth': P.globe,
'mdi:web': P.globe,
'mdi:clock': P.clock,
'mdi:clock-outline': P.clock,
'mdi:timer': P.timer,
'mdi:timer-outline': P.timer,
'mdi:heart': P.heart,
'mdi:heart-pulse': P.heart,
'mdi:motion-sensor': P.activity,
'mdi:run': P.activity,
'mdi:walk': P.activity,
'mdi:door': P.doorOpen,
'mdi:door-open': P.doorOpen,
'mdi:door-closed': P.doorOpen,
'mdi:window-open': P.doorOpen,
'mdi:window-closed': P.doorOpen,
'mdi:toggle-switch': P.toggleRight,
'mdi:toggle-switch-off': P.toggleRight,
'mdi:water': P.droplets,
'mdi:water-percent': P.droplets,
'mdi:humidity': P.droplets,
'mdi:fan': P.fan,
'mdi:fan-speed-1': P.fan,
'mdi:fan-speed-2': P.fan,
'mdi:fan-speed-3': P.fan,
'mdi:star': P.star,
'mdi:battery': P.zap,
'mdi:battery-charging': P.zap,
'mdi:gauge': P.activity,
'mdi:speedometer': P.activity,
'mdi:robot-vacuum': P.settings,
'mdi:cog': P.settings,
};
/** Map an MDI icon name (from HA entity) to a Lucide SVG icon. */
export function getMdiIcon(mdiName: string): string {
if (!mdiName) return _svg(P.listChecks);
const path = _mdiMap[mdiName];
if (path) return _svg(path);
// Fallback: try keyword matching
if (mdiName.includes('light')) return _svg(P.lightbulb);
if (mdiName.includes('sensor')) return _svg(P.activity);
if (mdiName.includes('switch')) return _svg(P.toggleRight);
if (mdiName.includes('door') || mdiName.includes('window')) return _svg(P.doorOpen);
return _svg(P.listChecks);
}
/** Map HA entity domain to an icon (fallback when no MDI icon is available). */
const _domainIcons: Record<string, string> = {
light: P.lightbulb,
switch: P.toggleRight,
sensor: P.activity,
binary_sensor: P.toggleRight,
climate: P.thermometer,
fan: P.fan,
cover: P.doorOpen,
lock: P.lock,
camera: P.camera,
media_player: P.music,
automation: P.refreshCw,
script: P.play,
scene: P.star,
input_boolean: P.toggleRight,
input_number: P.slidersHorizontal,
input_select: P.listChecks,
input_text: P.fileText,
timer: P.timer,
counter: P.hash,
person: P.smartphone,
device_tracker: P.mapPin,
zone: P.mapPin,
sun: P.sun,
weather: P.cloudSun,
update: P.download,
button: P.power,
number: P.slidersHorizontal,
select: P.listChecks,
text: P.fileText,
alarm_control_panel: P.bellRing,
water_heater: P.droplets,
vacuum: P.settings,
humidifier: P.droplets,
};
export function getHAEntityIcon(entity: { icon?: string; domain?: string }): string {
// Prefer explicit MDI icon if set
if (entity.icon) return getMdiIcon(entity.icon);
// Fall back to domain-based icon
const domainPath = _domainIcons[entity.domain || ''];
if (domainPath) return _svg(domainPath);
return _svg(P.listChecks);
}
// ── Entity-kind constants ───────────────────────────────────
export const ICON_AUTOMATION = _svg(P.clipboardList);
@@ -30,6 +30,8 @@ class AudioSourceModal extends Modal {
onForceClose() {
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
if (_asChannelIconSelect) { _asChannelIconSelect.destroy(); _asChannelIconSelect = null; }
if (_asBandIconSelect) { _asBandIconSelect.destroy(); _asBandIconSelect = null; }
}
snapshotValues() {
@@ -58,6 +60,7 @@ let _asDeviceEntitySelect: EntitySelect | null = null;
let _asParentEntitySelect: EntitySelect | null = null;
let _asBandParentEntitySelect: EntitySelect | null = null;
let _asBandIconSelect: IconSelect | null = null;
let _asChannelIconSelect: IconSelect | null = null;
const _svg = (d: string): string => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -136,7 +139,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
} else if (editData.source_type === 'mono') {
_loadMultichannelSources(editData.audio_source_id);
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
_ensureChannelIconSelect();
} else if (editData.source_type === 'band_extract') {
_loadBandParentSources(editData.audio_source_id);
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
@@ -155,7 +158,7 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
await _loadAudioDevices();
} else if (sourceType === 'mono') {
_loadMultichannelSources();
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
_ensureChannelIconSelect();
} else if (sourceType === 'band_extract') {
_loadBandParentSources();
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
@@ -426,6 +429,28 @@ function _ensureBandIconSelect() {
});
}
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _ensureChannelIconSelect() {
const sel = document.getElementById('audio-source-channel') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'mono', icon: _icon(P.headphones), label: t('audio_source.channel.mono'), desc: t('audio_source.channel.mono.desc') },
{ value: 'left', icon: _icon(P.volume2), label: t('audio_source.channel.left'), desc: t('audio_source.channel.left.desc') },
{ value: 'right', icon: _icon(P.volume2), label: t('audio_source.channel.right'), desc: t('audio_source.channel.right.desc') },
];
if (_asChannelIconSelect) {
_asChannelIconSelect.updateItems(items);
return;
}
_asChannelIconSelect = new IconSelect({
target: sel,
items,
columns: 3,
onChange: () => _autoGenerateAudioSourceName(),
});
}
function _loadBandParentSources(selectedId?: any) {
const select = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
if (!select) return;
@@ -3,6 +3,7 @@
*/
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
import { getHAEntityIcon } from '../core/icons.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
@@ -21,6 +22,33 @@ import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
// ── HA condition entity cache ──
let _haConditionEntities: any[] = [];
async function _loadHAEntitiesForCondition(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haConditionEntities = []; return; }
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _haConditionEntities = []; return; }
const data = await resp.json();
_haConditionEntities = data.entities || [];
} catch {
_haConditionEntities = [];
}
// Rebuild entity select options
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
if (entitySelect) {
const currentVal = entitySelect.value;
entitySelect.innerHTML = `<option value="">—</option>` +
_haConditionEntities.map((e: any) =>
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).join('');
if (currentVal && !_haConditionEntities.some((e: any) => e.entity_id === currentVal)) {
entitySelect.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
}
}
}
let _automationTagsInput: any = null;
// ── Auto-name ──
@@ -732,7 +760,6 @@ function addAutomationConditionRow(condition: any) {
const entityId = data.entity_id || '';
const haState = data.state || '';
const matchMode = data.match_mode || 'exact';
// Build HA source options from cached data
const haOptions = _cachedHASources.map((s: any) =>
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
@@ -748,7 +775,9 @@ function addAutomationConditionRow(condition: any) {
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.entity_id')}</label>
<input type="text" class="condition-ha-entity-id" value="${escapeHtml(entityId)}" placeholder="binary_sensor.front_door">
<select class="condition-ha-entity-id">
${entityId ? `<option value="${escapeHtml(entityId)}" selected>${escapeHtml(entityId)}</option>` : '<option value="">—</option>'}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.state')}</label>
@@ -763,6 +792,45 @@ function addAutomationConditionRow(condition: any) {
</select>
</div>
</div>`;
// Wire HA source EntitySelect
const haSrcSelect = container.querySelector('.condition-ha-source-id') as HTMLSelectElement;
new EntitySelect({
target: haSrcSelect,
getItems: () => _cachedHASources.map((s: any) => ({
value: s.id, label: s.name, icon: _icon(P.home),
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
})),
placeholder: t('palette.search'),
onChange: (newId: string) => _loadHAEntitiesForCondition(newId, container),
});
// Wire entity EntitySelect
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
const entityES = new EntitySelect({
target: entitySelect,
getItems: () => _haConditionEntities.map((e: any) => ({
value: e.entity_id, label: e.friendly_name || e.entity_id,
icon: getHAEntityIcon(e), desc: e.state || '',
})),
placeholder: t('ha_light.mapping.search_entity'),
});
// Wire match mode IconSelect
const matchSelect = container.querySelector('.condition-ha-match-mode') as HTMLSelectElement;
new IconSelect({
target: matchSelect,
items: [
{ value: 'exact', icon: _icon(P.check), label: t('automations.condition.mqtt.match_mode.exact'), desc: t('automations.condition.ha.match_mode.exact.desc') },
{ value: 'contains', icon: _icon(P.search), label: t('automations.condition.mqtt.match_mode.contains'), desc: t('automations.condition.ha.match_mode.contains.desc') },
{ value: 'regex', icon: _icon(P.code), label: t('automations.condition.mqtt.match_mode.regex'), desc: t('automations.condition.ha.match_mode.regex.desc') },
],
columns: 1,
});
// Load entities if source is already selected
if (haSourceId) _loadHAEntitiesForCondition(haSourceId, container);
return;
}
if (type === 'webhook') {
@@ -878,7 +946,7 @@ function getAutomationEditorConditions() {
conditions.push({
condition_type: 'home_assistant',
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLInputElement).value.trim(),
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLSelectElement).value.trim(),
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
});
@@ -13,6 +13,7 @@ import {
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -24,6 +25,7 @@ let _compositeSourceEntitySelects: any[] = [];
let _compositeBrightnessEntitySelects: any[] = [];
let _compositeBlendIconSelects: any[] = [];
let _compositeCSPTEntitySelects: any[] = [];
let _compositeOpacityWidgets: BindableScalarWidget[] = [];
/** Return current composite layers array (for dirty-check snapshot). */
export function compositeGetRawLayers() {
@@ -47,6 +49,8 @@ export function compositeDestroyEntitySelects() {
_compositeBlendIconSelects = [];
_compositeCSPTEntitySelects.forEach(es => es.destroy());
_compositeCSPTEntitySelects = [];
_compositeOpacityWidgets.forEach(w => w.destroy());
_compositeOpacityWidgets = [];
}
function _getCompositeBlendItems() {
@@ -140,10 +144,8 @@ export function compositeRenderList() {
<div class="composite-layer-row">
<label class="composite-layer-opacity-label">
<span>${t('color_strip.composite.opacity')}:</span>
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
</label>
<input type="range" class="composite-layer-opacity" data-idx="${i}"
min="0" max="1" step="0.05" value="${layer.opacity}">
<div class="composite-layer-opacity-container" data-idx="${i}"></div>
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
@@ -229,14 +231,6 @@ export function compositeRenderList() {
});
});
// Wire up live opacity display
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
el.addEventListener('input', () => {
const val = parseFloat(el.value);
(el.closest('.composite-layer-row')!.querySelector('.composite-opacity-val') as HTMLElement).textContent = val.toFixed(2);
});
});
// Attach IconSelect to each layer's blend mode dropdown
const blendItems = _getCompositeBlendItems();
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
@@ -275,6 +269,19 @@ export function compositeRenderList() {
}));
});
// Create BindableScalarWidget for each layer's opacity
list.querySelectorAll<HTMLElement>('.composite-layer-opacity-container').forEach((container, i) => {
const widget = new BindableScalarWidget({
container,
min: 0, max: 1, step: 0.05, default: 1.0,
idPrefix: `composite-opacity-${i}`,
format: (v) => v.toFixed(2),
valueSources: () => _cachedValueSources,
});
widget.setValue(_compositeLayers[i]?.opacity ?? 1.0);
_compositeOpacityWidgets.push(widget);
});
_initCompositeLayerDrag(list);
}
@@ -306,7 +313,6 @@ function _compositeLayersSyncFromDom() {
if (!list) return;
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
const opacities = list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity');
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
@@ -317,7 +323,7 @@ function _compositeLayersSyncFromDom() {
for (let i = 0; i < srcs.length; i++) {
_compositeLayers[i].source_id = srcs[i].value;
_compositeLayers[i].blend_mode = blends[i].value;
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
_compositeLayers[i].opacity = _compositeOpacityWidgets[i]?.getValue() ?? _compositeLayers[i].opacity;
_compositeLayers[i].enabled = enableds[i].checked;
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
@@ -13,8 +13,9 @@ import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
import { _cachedAssets, assetsCache } from '../core/state.ts';
import { _cachedAssets, _cachedValueSources, assetsCache } from '../core/state.ts';
import { getBaseOrigin } from './settings.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -36,6 +37,32 @@ export function notificationGetRawAppOverrides() {
let _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null;
let _notificationDurationWidget: BindableScalarWidget | null = null;
function _ensureNotificationDurationWidget(): BindableScalarWidget {
if (!_notificationDurationWidget) {
_notificationDurationWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-notification-duration-container')!,
min: 100, max: 5000, step: 100, default: 1500,
idPrefix: 'css-editor-notification-duration',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _notificationDurationWidget;
}
export function destroyNotificationDurationWidget(): void {
if (_notificationDurationWidget) { _notificationDurationWidget.destroy(); _notificationDurationWidget = null; }
}
export function getNotificationDurationValue(): number | { value: number; source_id: string } {
return _notificationDurationWidget ? _notificationDurationWidget.getValue() : 1500;
}
export function getNotificationDurationSnapshot(): string {
return _notificationDurationWidget ? JSON.stringify(_notificationDurationWidget.getValue()) : '1500';
}
export function ensureNotificationEffectIconSelect() {
const sel = document.getElementById('css-editor-notification-effect') as HTMLSelectElement | null;
@@ -346,9 +373,7 @@ export async function loadNotificationState(css: any) {
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
const dur = css.duration_ms ?? 1500;
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = dur;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = dur;
_ensureNotificationDurationWidget().setValue(css.duration_ms ?? 1500);
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = css.default_color || '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = css.app_filter_mode || 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
@@ -380,8 +405,7 @@ export async function resetNotificationState() {
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
(document.getElementById('css-editor-notification-duration') as HTMLInputElement).value = 1500 as any;
(document.getElementById('css-editor-notification-duration-val') as HTMLElement).textContent = '1500';
_ensureNotificationDurationWidget().setValue(1500);
(document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value = '#ffffff';
(document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
@@ -15,7 +15,7 @@ import {
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict } from './color-strips-notification.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue } from './color-strips-notification.ts';
/* ── Preview config builder ───────────────────────────────────── */
@@ -49,7 +49,7 @@ function _collectPreviewConfig() {
config = {
source_type: 'notification',
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500,
duration_ms: getNotificationDurationValue(),
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList,
@@ -3,7 +3,7 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -18,9 +18,11 @@ import {
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.ts';
import { bindableValue } from '../types.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
import { getBaseOrigin } from './settings.ts';
import {
rgbArrayToHex, hexToRgbArray,
@@ -39,6 +41,7 @@ import {
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
loadNotificationState, resetNotificationState, showNotificationEndpoint,
destroyNotificationDurationWidget, getNotificationDurationValue, getNotificationDurationSnapshot,
} from './color-strips-notification.ts';
// Re-export for app.js window global bindings
@@ -58,8 +61,22 @@ class CSSEditorModal extends Modal {
onForceClose() {
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
if (_smoothingWidget) { _smoothingWidget.destroy(); _smoothingWidget = null; }
if (_audioSensitivityWidget) { _audioSensitivityWidget.destroy(); _audioSensitivityWidget = null; }
if (_audioSmoothingWidget) { _audioSmoothingWidget.destroy(); _audioSmoothingWidget = null; }
if (_effectIntensityWidget) { _effectIntensityWidget.destroy(); _effectIntensityWidget = null; }
if (_effectScaleWidget) { _effectScaleWidget.destroy(); _effectScaleWidget = null; }
if (_apiInputTimeoutWidget) { _apiInputTimeoutWidget.destroy(); _apiInputTimeoutWidget = null; }
if (_candlelightIntensityWidget) { _candlelightIntensityWidget.destroy(); _candlelightIntensityWidget = null; }
if (_candlelightSpeedWidget) { _candlelightSpeedWidget.destroy(); _candlelightSpeedWidget = null; }
if (_candlelightWindWidget) { _candlelightWindWidget.destroy(); _candlelightWindWidget = null; }
if (_weatherSpeedWidget) { _weatherSpeedWidget.destroy(); _weatherSpeedWidget = null; }
if (_weatherTempInfluenceWidget) { _weatherTempInfluenceWidget.destroy(); _weatherTempInfluenceWidget = null; }
destroyNotificationDurationWidget();
if (_kcPictureSourceEntitySelect) { _kcPictureSourceEntitySelect.destroy(); _kcPictureSourceEntitySelect = null; }
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
if (_kcSmoothingWidget) { _kcSmoothingWidget.destroy(); _kcSmoothingWidget = null; }
if (_kcBrightnessWidget) { _kcBrightnessWidget.destroy(); _kcBrightnessWidget = null; }
compositeDestroyEntitySelects();
}
@@ -70,7 +87,7 @@ class CSSEditorModal extends Modal {
type,
picture_source: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
interpolation: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: (document.getElementById('css-editor-smoothing') as HTMLInputElement).value,
smoothing: _smoothingWidget ? JSON.stringify(_smoothingWidget.getValue()) : '0.3',
color: (document.getElementById('css-editor-color') as HTMLInputElement).value,
led_count: (document.getElementById('css-editor-led-count') as HTMLInputElement).value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
@@ -79,25 +96,25 @@ class CSSEditorModal extends Modal {
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
effect_palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
effect_color: (document.getElementById('css-editor-effect-color') as HTMLInputElement).value,
effect_intensity: (document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value,
effect_scale: (document.getElementById('css-editor-effect-scale') as HTMLInputElement).value,
effect_intensity: _effectIntensityWidget ? JSON.stringify(_effectIntensityWidget.getValue()) : '1.0',
effect_scale: _effectScaleWidget ? JSON.stringify(_effectScaleWidget.getValue()) : '1.0',
effect_mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
composite_layers: JSON.stringify(compositeGetRawLayers()),
mapped_zones: JSON.stringify(_mappedZones),
audio_viz: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
audio_source: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value,
audio_sensitivity: (document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value,
audio_smoothing: (document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value,
audio_sensitivity: _audioSensitivityWidget ? JSON.stringify(_audioSensitivityWidget.getValue()) : '1.0',
audio_smoothing: _audioSmoothingWidget ? JSON.stringify(_audioSmoothingWidget.getValue()) : '0.3',
audio_palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
audio_color: (document.getElementById('css-editor-audio-color') as HTMLInputElement).value,
audio_color_peak: (document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value,
audio_mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
api_input_fallback_color: (document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value,
api_input_timeout: (document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value,
api_input_timeout: _apiInputTimeoutWidget ? JSON.stringify(_apiInputTimeoutWidget.getValue()) : '5.0',
api_input_interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLInputElement).value,
notification_os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
notification_duration: (document.getElementById('css-editor-notification-duration') as HTMLInputElement).value,
notification_duration: getNotificationDurationSnapshot(),
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
@@ -107,11 +124,13 @@ class CSSEditorModal extends Modal {
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
daylight_latitude: (document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value,
candlelight_color: (document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value,
candlelight_intensity: (document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value,
candlelight_intensity: _candlelightIntensityWidget ? JSON.stringify(_candlelightIntensityWidget.getValue()) : '1.0',
candlelight_num_candles: (document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value,
candlelight_speed: (document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value,
candlelight_speed: _candlelightSpeedWidget ? JSON.stringify(_candlelightSpeedWidget.getValue()) : '1.0',
processed_input: (document.getElementById('css-editor-processed-input') as HTMLInputElement).value,
processed_template: (document.getElementById('css-editor-processed-template') as HTMLInputElement).value,
kc_smoothing: _kcSmoothingWidget ? JSON.stringify(_kcSmoothingWidget.getValue()) : '0.3',
kc_brightness: _kcBrightnessWidget ? JSON.stringify(_kcBrightnessWidget.getValue()) : '1.0',
kc_rects: JSON.stringify(_kcEditorRects),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
@@ -121,6 +140,17 @@ class CSSEditorModal extends Modal {
const cssEditorModal = new CSSEditorModal();
let _cssTagsInput: any = null;
let _smoothingWidget: BindableScalarWidget | null = null;
let _audioSensitivityWidget: BindableScalarWidget | null = null;
let _audioSmoothingWidget: BindableScalarWidget | null = null;
let _effectIntensityWidget: BindableScalarWidget | null = null;
let _effectScaleWidget: BindableScalarWidget | null = null;
let _apiInputTimeoutWidget: BindableScalarWidget | null = null;
let _candlelightIntensityWidget: BindableScalarWidget | null = null;
let _candlelightSpeedWidget: BindableScalarWidget | null = null;
let _candlelightWindWidget: BindableScalarWidget | null = null;
let _weatherSpeedWidget: BindableScalarWidget | null = null;
let _weatherTempInfluenceWidget: BindableScalarWidget | null = null;
// ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect: any = null;
@@ -130,6 +160,8 @@ let _processedInputEntitySelect: any = null;
let _processedTemplateEntitySelect: any = null;
let _kcPictureSourceEntitySelect: any = null;
let _kcInterpolationIconSelect: any = null;
let _kcSmoothingWidget: BindableScalarWidget | null = null;
let _kcBrightnessWidget: BindableScalarWidget | null = null;
// ── Key Colors rectangle editor state ──
let _kcEditorRects: Array<{ name: string; x: number; y: number; width: number; height: number }> = [];
@@ -480,6 +512,175 @@ function _ensureInterpolationIconSelect() {
_interpolationIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
function _ensureSmoothingWidget(): BindableScalarWidget {
const container = document.getElementById('css-editor-smoothing-container')!;
if (!_smoothingWidget) {
_smoothingWidget = new BindableScalarWidget({
container,
min: 0, max: 1, step: 0.05, default: 0.3,
idPrefix: 'css-editor-smoothing',
valueSources: () => _cachedValueSources,
});
}
return _smoothingWidget;
}
function _ensureKcSmoothingWidget(): BindableScalarWidget {
if (!_kcSmoothingWidget) {
_kcSmoothingWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-kc-smoothing-container')!,
min: 0, max: 1, step: 0.05, default: 0.3,
idPrefix: 'css-editor-kc-smoothing',
format: (v) => v.toFixed(2),
valueSources: () => _cachedValueSources,
});
}
return _kcSmoothingWidget;
}
function _ensureKcBrightnessWidget(): BindableScalarWidget {
if (!_kcBrightnessWidget) {
_kcBrightnessWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-kc-brightness-container')!,
min: 0, max: 1, step: 0.05, default: 1.0,
idPrefix: 'css-editor-kc-brightness',
format: (v) => v.toFixed(2),
valueSources: () => _cachedValueSources,
});
}
return _kcBrightnessWidget;
}
function _ensureAudioSensitivityWidget(): BindableScalarWidget {
if (!_audioSensitivityWidget) {
_audioSensitivityWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-audio-sensitivity-container')!,
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-audio-sensitivity',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _audioSensitivityWidget;
}
function _ensureAudioSmoothingWidget(): BindableScalarWidget {
if (!_audioSmoothingWidget) {
_audioSmoothingWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-audio-smoothing-container')!,
min: 0.0, max: 1.0, step: 0.05, default: 0.3,
idPrefix: 'css-editor-audio-smoothing',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(2),
});
}
return _audioSmoothingWidget;
}
function _ensureEffectIntensityWidget(): BindableScalarWidget {
if (!_effectIntensityWidget) {
_effectIntensityWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-effect-intensity-container')!,
min: 0.1, max: 2.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-effect-intensity',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _effectIntensityWidget;
}
function _ensureEffectScaleWidget(): BindableScalarWidget {
if (!_effectScaleWidget) {
_effectScaleWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-effect-scale-container')!,
min: 0.5, max: 5.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-effect-scale',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _effectScaleWidget;
}
function _ensureApiInputTimeoutWidget(): BindableScalarWidget {
if (!_apiInputTimeoutWidget) {
_apiInputTimeoutWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-api-input-timeout-container')!,
min: 0, max: 60, step: 0.5, default: 5.0,
idPrefix: 'css-editor-api-input-timeout',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _apiInputTimeoutWidget;
}
function _ensureCandlelightIntensityWidget(): BindableScalarWidget {
if (!_candlelightIntensityWidget) {
_candlelightIntensityWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-candlelight-intensity-container')!,
min: 0.1, max: 2.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-candlelight-intensity',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _candlelightIntensityWidget;
}
function _ensureCandlelightSpeedWidget(): BindableScalarWidget {
if (!_candlelightSpeedWidget) {
_candlelightSpeedWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-candlelight-speed-container')!,
min: 0.1, max: 5.0, step: 0.1, default: 1.0,
idPrefix: 'css-editor-candlelight-speed',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _candlelightSpeedWidget;
}
function _ensureCandlelightWindWidget(): BindableScalarWidget {
if (!_candlelightWindWidget) {
_candlelightWindWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-candlelight-wind-container')!,
min: 0.0, max: 2.0, step: 0.1, default: 0.0,
idPrefix: 'css-editor-candlelight-wind',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _candlelightWindWidget;
}
function _ensureWeatherSpeedWidget(): BindableScalarWidget {
if (!_weatherSpeedWidget) {
_weatherSpeedWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-weather-speed-container')!,
min: 0.1, max: 5, step: 0.1, default: 1.0,
idPrefix: 'css-editor-weather-speed',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _weatherSpeedWidget;
}
function _ensureWeatherTempInfluenceWidget(): BindableScalarWidget {
if (!_weatherTempInfluenceWidget) {
_weatherTempInfluenceWidget = new BindableScalarWidget({
container: document.getElementById('css-editor-weather-temp-influence-container')!,
min: 0, max: 1, step: 0.05, default: 0.5,
idPrefix: 'css-editor-weather-temp-influence',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(2),
});
}
return _weatherTempInfluenceWidget;
}
function _ensureApiInputInterpolationIconSelect() {
const sel = document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement | null;
if (!sel) return;
@@ -992,13 +1193,8 @@ function _loadAudioState(css: any) {
if (_audioVizIconSelect) _audioVizIconSelect.setValue(css.visualization_mode || 'spectrum');
onAudioVizChange();
const sensitivity = css.sensitivity ?? 1.0;
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = sensitivity;
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = parseFloat(sensitivity).toFixed(1);
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
_ensureAudioSensitivityWidget().setValue(css.sensitivity ?? 1.0);
_ensureAudioSmoothingWidget().setValue(css.smoothing ?? 0.3);
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
@@ -1017,10 +1213,8 @@ function _loadAudioState(css: any) {
function _resetAudioState() {
(document.getElementById('css-editor-audio-viz') as HTMLInputElement).value = 'spectrum';
if (_audioVizIconSelect) _audioVizIconSelect.setValue('spectrum');
(document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = '0.30';
_ensureAudioSensitivityWidget().setValue(1.0);
_ensureAudioSmoothingWidget().setValue(0.3);
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow');
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
@@ -1127,7 +1321,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
},
api_input: (source) => {
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
const timeoutVal = bindableValue(source.timeout, 5.0).toFixed(1);
const interpLabel = t('color_strip.api_input.interpolation.' + (source.interpolation || 'linear')) || source.interpolation || 'linear';
return `
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
@@ -1153,7 +1347,7 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
},
daylight: (source, { clockBadge }) => {
const useRealTime = source.use_real_time;
const speedVal = (source.speed ?? 1.0).toFixed(1);
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
return `
<span class="stream-card-prop">${useRealTime ? ICON_CLOCK + ' ' + t('color_strip.daylight.real_time') : ICON_FAST_FORWARD + ' ' + speedVal + 'x'}</span>
${clockBadge}
@@ -1171,8 +1365,8 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
`;
},
weather: (source, { clockBadge }) => {
const speedVal = (source.speed ?? 1.0).toFixed(1);
const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1);
const speedVal = bindableValue(source.speed, 1.0).toFixed(1);
const tempInfl = bindableValue(source.temperature_influence, 0.5).toFixed(1);
const ws = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id);
const wsName = ws?.name || '—';
const wsLink = ws
@@ -1420,10 +1614,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId);
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
_ensureEffectIntensityWidget().setValue(css.intensity ?? 1.0);
_ensureEffectScaleWidget().setValue(css.scale ?? 1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
onEffectPaletteChange();
},
@@ -1432,10 +1624,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
_ensureEffectIntensityWidget().setValue(1.0);
_ensureEffectScaleWidget().setValue(1.0);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
},
getPayload(name) {
@@ -1443,8 +1633,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name,
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value),
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
intensity: _ensureEffectIntensityWidget().getValue(),
scale: _ensureEffectScaleWidget().getValue(),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
};
// Meteor/comet/bouncing_ball use a color picker
@@ -1468,8 +1658,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name,
visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value),
smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value),
sensitivity: _ensureAudioSensitivityWidget().getValue(),
smoothing: _ensureAudioSmoothingWidget().getValue(),
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
@@ -1519,17 +1709,14 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
load(css) {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value =
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = css.timeout ?? 5.0;
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent =
parseFloat(css.timeout ?? 5.0).toFixed(1);
_ensureApiInputTimeoutWidget().setValue(css.timeout ?? 5.0);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = css.interpolation || 'linear';
_ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(css.id);
},
reset() {
(document.getElementById('css-editor-api-input-fallback-color') as HTMLInputElement).value = '#000000';
(document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value = 5.0 as any;
(document.getElementById('css-editor-api-input-timeout-val') as HTMLElement).textContent = '5.0';
_ensureApiInputTimeoutWidget().setValue(5.0);
(document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value = 'linear';
_ensureApiInputInterpolationIconSelect();
_showApiInputEndpoints(null);
@@ -1539,7 +1726,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return {
name,
fallback_color: hexToRgbArray(fbHex),
timeout: parseFloat((document.getElementById('css-editor-api-input-timeout') as HTMLInputElement).value),
timeout: _ensureApiInputTimeoutWidget().getValue(),
interpolation: (document.getElementById('css-editor-api-input-interpolation') as HTMLSelectElement).value,
};
},
@@ -1558,7 +1745,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
name,
os_listener: (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked,
notification_effect: (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value,
duration_ms: parseInt((document.getElementById('css-editor-notification-duration') as HTMLInputElement).value) || 1500,
duration_ms: getNotificationDurationValue(),
default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList,
@@ -1602,25 +1789,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
candlelight: {
load(css) {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 147, 41]);
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
_ensureCandlelightIntensityWidget().setValue(css.intensity ?? 1.0);
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = css.speed ?? 1.0;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = css.wind_strength ?? 0.0;
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = parseFloat(css.wind_strength ?? 0.0).toFixed(1);
_ensureCandlelightSpeedWidget().setValue(css.speed ?? 1.0);
_ensureCandlelightWindWidget().setValue(css.wind_strength ?? 0.0);
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
},
reset() {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
(document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-candlelight-intensity-val') as HTMLElement).textContent = '1.0';
_ensureCandlelightIntensityWidget().setValue(1.0);
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = 0.0 as any;
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = '0.0';
_ensureCandlelightSpeedWidget().setValue(1.0);
_ensureCandlelightWindWidget().setValue(0.0);
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
},
@@ -1628,10 +1809,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return {
name,
color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value),
intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value),
intensity: _ensureCandlelightIntensityWidget().getValue(),
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value),
wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value),
speed: _ensureCandlelightSpeedWidget().getValue(),
wind_strength: _ensureCandlelightWindWidget().getValue(),
candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value,
};
},
@@ -1641,19 +1822,15 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
await weatherSourcesCache.fetch();
_populateWeatherSourceDropdown();
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || '';
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = css.speed ?? 1.0;
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = css.temperature_influence ?? 0.5;
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = parseFloat(css.temperature_influence ?? 0.5).toFixed(2);
_ensureWeatherSpeedWidget().setValue(css.speed ?? 1.0);
_ensureWeatherTempInfluenceWidget().setValue(css.temperature_influence ?? 0.5);
},
async reset() {
await weatherSourcesCache.fetch();
_populateWeatherSourceDropdown();
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = 0.5 as any;
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = '0.50';
_ensureWeatherSpeedWidget().setValue(1.0);
_ensureWeatherTempInfluenceWidget().setValue(0.5);
},
getPayload(name) {
const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value;
@@ -1664,8 +1841,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
return {
name,
weather_source_id: wsId,
speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value),
temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value),
speed: _ensureWeatherSpeedWidget().getValue(),
temperature_influence: _ensureWeatherTempInfluenceWidget().getValue(),
};
},
},
@@ -1702,21 +1879,18 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
load(css) {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
_ensureSmoothingWidget().setValue(css.smoothing);
},
reset() {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
_ensureSmoothingWidget().setValue(0.3);
},
getPayload(name) {
return {
name,
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value),
smoothing: _ensureSmoothingWidget().getValue(),
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
};
},
@@ -1726,22 +1900,19 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
sourceSelect.value = css.picture_source_id || '';
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
_ensureSmoothingWidget().setValue(css.smoothing);
},
reset() {
(document.getElementById('css-editor-interpolation') as HTMLInputElement).value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
(document.getElementById('css-editor-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-smoothing-value') as HTMLElement).textContent = '0.30';
_ensureSmoothingWidget().setValue(0.3);
},
getPayload(name) {
return {
name,
picture_source_id: (document.getElementById('css-editor-picture-source') as HTMLInputElement).value,
interpolation_mode: (document.getElementById('css-editor-interpolation') as HTMLInputElement).value,
smoothing: parseFloat((document.getElementById('css-editor-smoothing') as HTMLInputElement).value),
smoothing: _ensureSmoothingWidget().getValue(),
led_count: parseInt((document.getElementById('css-editor-led-count') as HTMLInputElement).value) || 0,
};
},
@@ -1775,11 +1946,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
columns: 1,
});
const smoothing = css.smoothing ?? 0.3;
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = smoothing;
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = css.brightness ?? 1.0;
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = parseFloat(css.brightness ?? 1.0).toFixed(2);
_ensureKcSmoothingWidget().setValue(css.smoothing ?? 0.3);
_ensureKcBrightnessWidget().setValue(css.brightness ?? 1.0);
// Load rectangles
_kcEditorRects = (css.rectangles || []).map((r: any) => ({ ...r }));
_renderKCRectSummary();
@@ -1810,10 +1978,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
columns: 1,
});
(document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value = 0.3 as any;
(document.getElementById('css-editor-kc-smoothing-val') as HTMLElement).textContent = '0.30';
(document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-kc-brightness-val') as HTMLElement).textContent = '1.00';
_ensureKcSmoothingWidget().setValue(0.3);
_ensureKcBrightnessWidget().setValue(1.0);
_kcEditorRects = [{ name: 'Region 1', x: 0.0, y: 0.0, width: 1.0, height: 1.0 }];
_renderKCRectSummary();
},
@@ -1832,8 +1998,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
picture_source_id: psId,
rectangles: _kcEditorRects.map(r => ({ name: r.name, x: r.x, y: r.y, width: r.width, height: r.height })),
interpolation_mode: (document.getElementById('css-editor-kc-interpolation') as HTMLSelectElement).value,
smoothing: parseFloat((document.getElementById('css-editor-kc-smoothing') as HTMLInputElement).value),
brightness: parseFloat((document.getElementById('css-editor-kc-brightness') as HTMLInputElement).value),
smoothing: _ensureKcSmoothingWidget().getValue(),
brightness: _ensureKcBrightnessWidget().getValue(),
};
},
},
@@ -7,11 +7,13 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon } from '../core/icons.ts';
import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_FPS, ICON_OK, ICON_WARNING, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { bindableSourceId, bindableValue } from '../types.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
const ICON_HA = `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -25,6 +27,10 @@ let _brightnessVsEntitySelect: EntitySelect | null = null;
let _mappingEntitySelects: EntitySelect[] = [];
let _editorCssSources: any[] = [];
let _cachedHAEntities: any[] = []; // fetched from selected HA source
let _updateRateWidget: BindableScalarWidget | null = null;
let _transitionWidget: BindableScalarWidget | null = null;
let _colorToleranceWidget: BindableScalarWidget | null = null;
let _minBrightnessThresholdWidget: BindableScalarWidget | null = null;
class HALightEditorModal extends Modal {
constructor() { super('ha-light-editor-modal'); }
@@ -34,6 +40,10 @@ class HALightEditorModal extends Modal {
if (_haSourceEntitySelect) { _haSourceEntitySelect.destroy(); _haSourceEntitySelect = null; }
if (_cssSourceEntitySelect) { _cssSourceEntitySelect.destroy(); _cssSourceEntitySelect = null; }
if (_brightnessVsEntitySelect) { _brightnessVsEntitySelect.destroy(); _brightnessVsEntitySelect = null; }
if (_updateRateWidget) { _updateRateWidget.destroy(); _updateRateWidget = null; }
if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; }
if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; }
if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; }
_destroyMappingEntitySelects();
}
@@ -42,8 +52,10 @@ class HALightEditorModal extends Modal {
name: (document.getElementById('ha-light-editor-name') as HTMLInputElement).value,
ha_source: (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value,
css_source: (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value,
update_rate: (document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value,
transition: (document.getElementById('ha-light-editor-transition') as HTMLInputElement).value,
update_rate: _updateRateWidget ? JSON.stringify(_updateRateWidget.getValue()) : '2.0',
transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5',
color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5',
min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0',
mappings: _getMappingsJSON(),
tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []),
};
@@ -77,7 +89,7 @@ function _getEntityItems() {
.map((e: any) => ({
value: e.entity_id,
label: e.friendly_name || e.entity_id,
icon: _icon(P.lightbulb),
icon: getHAEntityIcon(e),
desc: e.state || '',
}));
}
@@ -201,6 +213,60 @@ export function removeHALightMapping(btn: HTMLElement): void {
row.remove();
}
// ── Bindable scalar widgets ──
function _ensureUpdateRateWidget(): BindableScalarWidget {
if (!_updateRateWidget) {
_updateRateWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-update-rate-container')!,
min: 0.5, max: 5.0, step: 0.1, default: 2.0,
idPrefix: 'ha-light-editor-update-rate',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _updateRateWidget;
}
function _ensureTransitionWidget(): BindableScalarWidget {
if (!_transitionWidget) {
_transitionWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-transition-container')!,
min: 0.0, max: 10.0, step: 0.1, default: 0.5,
idPrefix: 'ha-light-editor-transition',
valueSources: () => _cachedValueSources,
format: (v) => v.toFixed(1),
});
}
return _transitionWidget;
}
function _ensureColorToleranceWidget(): BindableScalarWidget {
if (!_colorToleranceWidget) {
_colorToleranceWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-color-tolerance-container')!,
min: 0, max: 50, step: 1, default: 5,
idPrefix: 'ha-light-editor-color-tolerance',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _colorToleranceWidget;
}
function _ensureMinBrightnessThresholdWidget(): BindableScalarWidget {
if (!_minBrightnessThresholdWidget) {
_minBrightnessThresholdWidget = new BindableScalarWidget({
container: document.getElementById('ha-light-editor-min-brightness-threshold-container')!,
min: 0, max: 254, step: 1, default: 0,
idPrefix: 'ha-light-editor-min-brightness-threshold',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _minBrightnessThresholdWidget;
}
// ── Show / Close ──
export async function showHALightEditor(targetId: string | null = null, cloneData: any = null): Promise<void> {
@@ -256,10 +322,10 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = editData.name || '';
haSelect.value = editData.ha_source_id || '';
cssSelect.value = editData.color_strip_source_id || '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = String(editData.update_rate ?? 2.0);
document.getElementById('ha-light-editor-update-rate-display')!.textContent = (editData.update_rate ?? 2.0).toFixed(1);
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = String(editData.ha_transition ?? 0.5);
document.getElementById('ha-light-editor-transition-display')!.textContent = (editData.ha_transition ?? 0.5).toFixed(1);
_ensureUpdateRateWidget().setValue(editData.update_rate ?? 2.0);
_ensureTransitionWidget().setValue(editData.transition ?? 0.5);
_ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5);
_ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || '';
// Fetch entities from the selected HA source before loading mappings
@@ -270,10 +336,10 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
mappings.forEach((m: any) => addHALightMapping(m));
} else {
(document.getElementById('ha-light-editor-name') as HTMLInputElement).value = '';
(document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value = '2.0';
document.getElementById('ha-light-editor-update-rate-display')!.textContent = '2.0';
(document.getElementById('ha-light-editor-transition') as HTMLInputElement).value = '0.5';
document.getElementById('ha-light-editor-transition-display')!.textContent = '0.5';
_ensureUpdateRateWidget().setValue(2.0);
_ensureTransitionWidget().setValue(0.5);
_ensureColorToleranceWidget().setValue(5);
_ensureMinBrightnessThresholdWidget().setValue(0);
(document.getElementById('ha-light-editor-description') as HTMLInputElement).value = '';
// Fetch entities from the first HA source
@@ -313,7 +379,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
const bvsSelect = document.getElementById('ha-light-editor-brightness-vs') as HTMLSelectElement;
bvsSelect.innerHTML = `<option value="">${t('targets.brightness_vs.none')}</option>` +
_cachedValueSources.map((vs: any) =>
`<option value="${vs.id}" ${vs.id === (editData?.brightness_value_source_id || '') ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
`<option value="${vs.id}" ${vs.id === bindableSourceId(editData?.brightness) ? 'selected' : ''}>${escapeHtml(vs.name)}</option>`
).join('');
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
_brightnessVsEntitySelect = new EntitySelect({
@@ -346,9 +412,10 @@ export async function saveHALightEditor(): Promise<void> {
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
const cssSourceId = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
const updateRate = parseFloat((document.getElementById('ha-light-editor-update-rate') as HTMLInputElement).value) || 2.0;
const transitionRaw = parseFloat((document.getElementById('ha-light-editor-transition') as HTMLInputElement).value);
const transition = isNaN(transitionRaw) ? 0.5 : transitionRaw;
const updateRate = _updateRateWidget ? _updateRateWidget.getValue() : 2.0;
const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5;
const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5;
const minBrightnessThreshold = _minBrightnessThresholdWidget ? _minBrightnessThresholdWidget.getValue() : 0;
const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null;
if (!name) {
@@ -369,10 +436,12 @@ export async function saveHALightEditor(): Promise<void> {
name,
ha_source_id: haSourceId,
color_strip_source_id: cssSourceId,
brightness_value_source_id: brightnessVsId,
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
ha_light_mappings: mappings,
update_rate: updateRate,
transition,
color_tolerance: colorTolerance,
min_brightness_threshold: minBrightnessThreshold,
description,
tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [],
};
@@ -450,7 +519,7 @@ export function createHALightTargetCard(target: any, haSourceMap: Record<string,
: '';
// Brightness value source
const bvsId = target.brightness_value_source_id || '';
const bvsId = bindableSourceId(target.brightness);
const bvs = bvsId && valueSourceMap[bvsId] ? valueSourceMap[bvsId] : null;
return wrapCard({
@@ -36,6 +36,8 @@ import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash, updateTabBadge } from './tabs.ts';
import type { OutputTarget } from '../types.ts';
import { bindableSourceId, bindableValue } from '../types.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
@@ -142,6 +144,8 @@ function _updateTargetFpsChart(targetId: any, fpsTarget: any) {
// --- Editor state ---
let _editorCssSources: any[] = []; // populated when editor opens
let _targetTagsInput: TagInput | null = null;
let _fpsWidget: BindableScalarWidget | null = null;
let _thresholdWidget: BindableScalarWidget | null = null;
class TargetEditorModal extends Modal {
constructor() {
@@ -155,8 +159,8 @@ class TargetEditorModal extends Modal {
protocol: (document.getElementById('target-editor-protocol') as HTMLSelectElement).value,
css_source: (document.getElementById('target-editor-css-source') as HTMLSelectElement).value,
brightness_vs: (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value,
brightness_threshold: (document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value,
fps: (document.getElementById('target-editor-fps') as HTMLInputElement).value,
brightness_threshold: _thresholdWidget ? JSON.stringify(_thresholdWidget.getValue()) : '0',
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
@@ -329,6 +333,32 @@ function _ensureProtocolIconSelect() {
_protocolIconSelect = new IconSelect({ target: sel as HTMLSelectElement, items, columns: 2 });
}
function _ensureFpsWidget(): BindableScalarWidget {
if (!_fpsWidget) {
_fpsWidget = new BindableScalarWidget({
container: document.getElementById('target-editor-fps-container')!,
min: 1, max: 90, step: 1, default: 30,
idPrefix: 'target-editor-fps',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _fpsWidget;
}
function _ensureThresholdWidget(): BindableScalarWidget {
if (!_thresholdWidget) {
_thresholdWidget = new BindableScalarWidget({
container: document.getElementById('target-editor-brightness-threshold-container')!,
min: 0, max: 254, step: 1, default: 0,
idPrefix: 'target-editor-brightness-threshold',
valueSources: () => _cachedValueSources,
format: (v) => String(Math.round(v)),
});
}
return _thresholdWidget;
}
export async function showTargetEditor(targetId: string | null = null, cloneData: any = null) {
try {
// Load devices, CSS sources, and value sources for dropdowns
@@ -365,56 +395,46 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-id') as HTMLInputElement).value = target.id;
(document.getElementById('target-editor-name') as HTMLInputElement).value = target.name;
deviceSelect.value = target.device_id || '';
const fps = target.fps ?? 30;
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
_ensureFpsWidget().setValue(target.fps ?? 30);
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = target.keepalive_interval ?? 1.0;
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = target.keepalive_interval ?? 1.0;
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
const thresh = target.min_brightness_threshold ?? 0;
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = thresh;
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = thresh;
_ensureThresholdWidget().setValue(target.min_brightness_threshold ?? 0);
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
_populateCssDropdown(target.color_strip_source_id || '');
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
_populateBrightnessVsDropdown(bindableSourceId(target.brightness));
} else if (cloneData) {
// Cloning — create mode but pre-filled from clone data
_editorTags = cloneData.tags || [];
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
(document.getElementById('target-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
deviceSelect.value = cloneData.device_id || '';
const fps = cloneData.fps ?? 30;
(document.getElementById('target-editor-fps') as HTMLInputElement).value = fps;
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = fps;
_ensureFpsWidget().setValue(cloneData.fps ?? 30);
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = cloneData.keepalive_interval ?? 1.0;
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = cloneData.keepalive_interval ?? 1.0;
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
const cloneThresh = cloneData.min_brightness_threshold ?? 0;
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = cloneThresh;
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = cloneThresh;
_ensureThresholdWidget().setValue(cloneData.min_brightness_threshold ?? 0);
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
_populateCssDropdown(cloneData.color_strip_source_id || '');
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
_populateBrightnessVsDropdown(bindableSourceId(cloneData.brightness));
} else {
// Creating new target
(document.getElementById('target-editor-id') as HTMLInputElement).value = '';
(document.getElementById('target-editor-name') as HTMLInputElement).value = '';
(document.getElementById('target-editor-fps') as HTMLInputElement).value = 30 as any;
(document.getElementById('target-editor-fps-value') as HTMLElement).textContent = '30';
_ensureFpsWidget().setValue(30);
(document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('target-editor-keepalive-interval-value') as HTMLElement).textContent = '1.0';
(document.getElementById('target-editor-title') as HTMLElement).innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
(document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value = 0 as any;
(document.getElementById('target-editor-brightness-threshold-value') as HTMLElement).textContent = '0';
_ensureThresholdWidget().setValue(0);
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
@@ -472,6 +492,8 @@ export async function closeTargetEditorModal() {
export function forceCloseTargetEditorModal() {
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
if (_fpsWidget) { _fpsWidget.destroy(); _fpsWidget = null; }
if (_thresholdWidget) { _thresholdWidget.destroy(); _thresholdWidget = null; }
targetEditorModal.forceClose();
}
@@ -486,11 +508,11 @@ export async function saveTargetEditor() {
return;
}
const fps = parseInt((document.getElementById('target-editor-fps') as HTMLInputElement).value) || 30;
const fps = _fpsWidget ? _fpsWidget.getValue() : 30;
const colorStripSourceId = (document.getElementById('target-editor-css-source') as HTMLSelectElement).value;
const brightnessVsId = (document.getElementById('target-editor-brightness-vs') as HTMLSelectElement).value;
const minBrightnessThreshold = parseInt((document.getElementById('target-editor-brightness-threshold') as HTMLInputElement).value) || 0;
const minBrightnessThreshold = _thresholdWidget ? _thresholdWidget.getValue() : 0;
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
@@ -499,7 +521,7 @@ export async function saveTargetEditor() {
name,
device_id: deviceId,
color_strip_source_id: colorStripSourceId,
brightness_value_source_id: brightnessVsId,
brightness: brightnessVsId ? { value: 1.0, source_id: brightnessVsId } : 1.0,
min_brightness_threshold: minBrightnessThreshold,
fps,
keepalive_interval: standbyInterval,
@@ -958,7 +980,7 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
const cssId = target.color_strip_source_id || '';
const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
const bvsId = target.brightness_value_source_id || '';
const bvsId = bindableSourceId(target.brightness);
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
// Determine if overlay is available (picture-based CSS)
@@ -990,11 +1012,11 @@ export function createTargetCard(target: OutputTarget & { state?: any; metrics?:
</div>
<div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${bindableValue(target.fps, 30)}</span>
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
${(target.min_brightness_threshold ?? 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${target.min_brightness_threshold} → off</span>` : ''}
${bindableValue(target.min_brightness_threshold, 0) > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} &lt;${bindableValue(target.min_brightness_threshold, 0)} → off</span>` : ''}
</div>
${renderTagChips(target.tags)}
<div class="card-content">
+37 -17
View File
@@ -5,6 +5,25 @@
* snake_case to match the JSON payloads — no camelCase transformation is done.
*/
// ── Bindable Float ───────────────────────────────────────────
// A scalar that is either a static value (plain number) or bound to a value source (dict).
export type BindableFloat = number | { value: number; source_id: string };
/** Extract the static value from a BindableFloat. */
export function bindableValue(b: BindableFloat | undefined, fallback: number): number {
if (b === undefined || b === null) return fallback;
if (typeof b === 'number') return b;
return b.value ?? fallback;
}
/** Extract the source_id from a BindableFloat (empty string = not bound). */
export function bindableSourceId(b: BindableFloat | undefined): string {
if (b === undefined || b === null) return '';
if (typeof b === 'number') return '';
return b.source_id ?? '';
}
// ── Device ────────────────────────────────────────────────────
export type DeviceType =
@@ -59,27 +78,27 @@ export interface OutputTarget {
// LED target fields
device_id?: string;
color_strip_source_id?: string;
brightness_value_source_id?: string;
fps?: number;
brightness?: BindableFloat;
fps?: BindableFloat;
keepalive_interval?: number;
state_check_interval?: number;
min_brightness_threshold?: number;
min_brightness_threshold?: BindableFloat;
adaptive_fps?: boolean;
protocol?: string;
// HA light target fields
ha_source_id?: string;
ha_light_mappings?: HALightMapping[];
update_rate?: number;
ha_transition?: number;
color_tolerance?: number;
update_rate?: BindableFloat;
transition?: BindableFloat;
color_tolerance?: BindableFloat;
}
export interface HALightMapping {
entity_id: string;
led_start: number;
led_end: number;
brightness_scale: number;
brightness_scale: BindableFloat;
}
// ── Color Strip Source ────────────────────────────────────────
@@ -165,7 +184,7 @@ export interface ColorStripSource {
// Picture
picture_source_id?: string;
smoothing?: number;
smoothing?: BindableFloat;
interpolation_mode?: string;
calibration?: Calibration;
@@ -181,8 +200,8 @@ export interface ColorStripSource {
// Effect
effect_type?: string;
palette?: string;
intensity?: number;
scale?: number;
intensity?: BindableFloat;
scale?: BindableFloat;
mirror?: boolean;
// Composite
@@ -194,16 +213,16 @@ export interface ColorStripSource {
// Audio
visualization_mode?: string;
audio_source_id?: string;
sensitivity?: number;
sensitivity?: BindableFloat;
color_peak?: number[];
// Animation
animation?: AnimationConfig;
speed?: number;
speed?: BindableFloat;
// API Input
fallback_color?: number[];
timeout?: number;
timeout?: BindableFloat;
interpolation?: string;
// Notification
@@ -214,6 +233,7 @@ export interface ColorStripSource {
app_filter_mode?: string;
app_filter_list?: string[];
os_listener?: boolean;
sound_volume?: BindableFloat;
// Daylight
use_real_time?: boolean;
@@ -221,6 +241,7 @@ export interface ColorStripSource {
// Candlelight
num_candles?: number;
wind_strength?: BindableFloat;
// Processed
input_source_id?: string;
@@ -228,12 +249,11 @@ export interface ColorStripSource {
// Weather
weather_source_id?: string;
temperature_influence?: number;
temperature_influence?: BindableFloat;
// Key Colors
rectangles?: KeyColorRectangle[];
brightness?: number;
brightness_value_source_id?: string;
brightness?: BindableFloat;
}
// ── Pattern Template ──────────────────────────────────────────
@@ -370,7 +390,7 @@ export interface TargetSnapshot {
target_id: string;
running: boolean;
color_strip_source_id: string;
brightness_value_source_id: string;
brightness?: BindableFloat;
fps: number;
}
@@ -1,5 +1,7 @@
{
"app.title": "LED Grab",
"bindable.none": "None (static value)",
"bindable.toggle": "Toggle value source binding",
"app.version": "Version:",
"app.api_docs": "API Documentation",
"app.connection_lost": "Server unreachable",
@@ -1331,9 +1333,12 @@
"audio_source.parent.hint": "Multichannel source to extract a channel from",
"audio_source.channel": "Channel:",
"audio_source.channel.hint": "Which audio channel to extract from the multichannel source",
"audio_source.channel.mono": "Mono (L+R mix)",
"audio_source.channel.mono": "Mono",
"audio_source.channel.mono.desc": "L+R mix",
"audio_source.channel.left": "Left",
"audio_source.channel.left.desc": "Left channel only",
"audio_source.channel.right": "Right",
"audio_source.channel.right.desc": "Right channel only",
"audio_source.description": "Description (optional):",
"audio_source.description.placeholder": "Describe this audio source...",
"audio_source.description.hint": "Optional notes about this audio source",
@@ -1808,6 +1813,10 @@
"ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.",
"ha_light.transition": "Transition:",
"ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).",
"ha_light.color_tolerance": "Color Tolerance:",
"ha_light.color_tolerance.hint": "Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.",
"ha_light.min_brightness_threshold": "Min Brightness Threshold:",
"ha_light.min_brightness_threshold.hint": "Effective output brightness below this value turns lights off completely (0 = disabled).",
"ha_light.mappings": "Light Mappings:",
"ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.",
"ha_light.mappings.add": "Add Mapping",
@@ -1830,6 +1839,9 @@
"automations.condition.home_assistant.state": "State:",
"automations.condition.home_assistant.match_mode": "Match Mode:",
"automations.condition.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
"automations.condition.ha.match_mode.exact.desc": "State must match exactly",
"automations.condition.ha.match_mode.contains.desc": "State must contain the text",
"automations.condition.ha.match_mode.regex.desc": "State must match the regex pattern",
"color_strip.clock": "Sync Clock:",
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
"graph.title": "Graph",
@@ -1,5 +1,11 @@
{
"app.title": "LED Grab",
"bindable.none": "Нет (статическое значение)",
"bindable.toggle": "Привязка к источнику значений",
"ha_light.color_tolerance": "Допуск цвета:",
"ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.",
"ha_light.min_brightness_threshold": "Мин. порог яркости:",
"ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).",
"app.version": "Версия:",
"app.api_docs": "Документация API",
"app.connection_lost": "Сервер недоступен",
@@ -1,5 +1,11 @@
{
"app.title": "LED Grab",
"bindable.none": "无(静态值)",
"bindable.toggle": "切换值源绑定",
"ha_light.color_tolerance": "色彩容差:",
"ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。",
"ha_light.min_brightness_threshold": "最低亮度阈值:",
"ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。",
"app.version": "版本:",
"app.api_docs": "API 文档",
"app.connection_lost": "服务器不可达",