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
@@ -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',
});