feat(value-sources): extend storage + schema + UI alongside new kinds

Storage model + Pydantic schema + route gain fields for the value-source
kinds introduced by the per-type factory refactor. Frontend editor adds
inputs for them and the modal template grows new field rows.
This commit is contained in:
2026-05-23 00:48:48 +03:00
parent 3fe66d80cb
commit 737fd72b73
5 changed files with 244 additions and 1 deletions
@@ -23,6 +23,7 @@ from ledgrab.api.schemas.value_sources import (
DaylightValueSourceResponse,
GradientMapValueSourceResponse,
HAEntityValueSourceResponse,
HTTPValueSourceResponse,
StaticColorValueSourceResponse,
StaticValueSourceResponse,
SystemMetricsValueSourceResponse,
@@ -41,6 +42,7 @@ from ledgrab.storage.value_source import (
DaylightValueSource,
GradientMapValueSource,
HAEntityValueSource,
HTTPValueSource,
StaticColorValueSource,
StaticValueSource,
SystemMetricsValueSource,
@@ -213,6 +215,22 @@ _RESPONSE_MAP = {
poll_interval=s.poll_interval,
smoothing=s.smoothing,
),
HTTPValueSource: lambda s: HTTPValueSourceResponse(
id=s.id,
name=s.name,
description=s.description,
tags=s.tags,
icon=getattr(s, "icon", "") or "",
icon_color=getattr(s, "icon_color", "") or "",
created_at=s.created_at,
updated_at=s.updated_at,
http_endpoint_id=s.http_endpoint_id,
json_path=s.json_path,
interval_s=s.interval_s,
min_value=s.min_value,
max_value=s.max_value,
smoothing=s.smoothing,
),
}
@@ -151,6 +151,17 @@ class SystemMetricsValueSourceResponse(_ValueSourceResponseBase):
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
class HTTPValueSourceResponse(_ValueSourceResponseBase):
source_type: Literal["http"] = "http"
return_type: Literal["float"] = "float"
http_endpoint_id: str = Field(description="HTTP endpoint ID")
json_path: str = Field(description="Dot-path into the response body")
interval_s: int = Field(description="Polling cadence (seconds)")
min_value: float = Field(description="Raw value mapped to output 0.0")
max_value: float = Field(description="Raw value mapped to output 1.0")
smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)")
ValueSourceResponse = Annotated[
Union[
Annotated[StaticValueSourceResponse, Tag("static")],
@@ -166,6 +177,7 @@ ValueSourceResponse = Annotated[
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
Annotated[HTTPValueSourceResponse, Tag("http")],
],
Discriminator("source_type"),
]
@@ -310,6 +322,16 @@ class SystemMetricsValueSourceCreate(_ValueSourceCreateBase):
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
class HTTPValueSourceCreate(_ValueSourceCreateBase):
source_type: Literal["http"] = "http"
http_endpoint_id: str = Field(description="HTTP endpoint ID")
json_path: str = Field("", description="Dot-path into the response (empty = raw body text)")
interval_s: int = Field(60, description="Polling cadence (seconds)", ge=1)
min_value: float = Field(0.0, description="Raw value mapped to output 0.0")
max_value: float = Field(100.0, description="Raw value mapped to output 1.0")
smoothing: float = Field(0.0, description="EMA smoothing (0.0-1.0)", ge=0.0, le=1.0)
ValueSourceCreate = Annotated[
Union[
Annotated[StaticValueSourceCreate, Tag("static")],
@@ -325,6 +347,7 @@ ValueSourceCreate = Annotated[
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
Annotated[HTTPValueSourceCreate, Tag("http")],
],
Discriminator("source_type"),
]
@@ -463,6 +486,16 @@ class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase):
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
class HTTPValueSourceUpdate(_ValueSourceUpdateBase):
source_type: Literal["http"] = "http"
http_endpoint_id: Optional[str] = Field(None, description="HTTP endpoint ID")
json_path: Optional[str] = Field(None, description="Dot-path into the response")
interval_s: Optional[int] = Field(None, description="Polling cadence (seconds)", ge=1)
min_value: Optional[float] = Field(None, description="Raw value mapped to 0.0")
max_value: Optional[float] = Field(None, description="Raw value mapped to 1.0")
smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0)
ValueSourceUpdate = Annotated[
Union[
Annotated[StaticValueSourceUpdate, Tag("static")],
@@ -478,6 +511,7 @@ ValueSourceUpdate = Annotated[
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
Annotated[HTTPValueSourceUpdate, Tag("http")],
],
Discriminator("source_type"),
]
@@ -15,6 +15,7 @@ import {
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
_cachedSyncClocks, syncClocksCache,
_cachedHTTPEndpoints, httpEndpointsCache,
getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
@@ -74,6 +75,7 @@ let _vsGradientEasingIconSelect: IconSelect | null = null;
let _vsBehaviorIconSelect: IconSelect | null = null;
let _vsMetricIconSelect: IconSelect | null = null;
let _vsAnimColorClockEntitySelect: EntitySelect | null = null;
let _vsHTTPEndpointEntitySelect: EntitySelect | null = null;
let _vsTagsInput: TagInput | null = null;
class ValueSourceModal extends Modal {
@@ -92,6 +94,7 @@ class ValueSourceModal extends Modal {
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
if (_vsAnimColorClockEntitySelect) { _vsAnimColorClockEntitySelect.destroy(); _vsAnimColorClockEntitySelect = null; }
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
if (_vsHTTPEndpointEntitySelect) { _vsHTTPEndpointEntitySelect.destroy(); _vsHTTPEndpointEntitySelect = null; }
}
snapshotValues() {
@@ -136,6 +139,13 @@ class ValueSourceModal extends Modal {
sensorLabel: (document.getElementById('value-source-sensor-label') as HTMLInputElement).value,
pollInterval: (document.getElementById('value-source-poll-interval') as HTMLInputElement).value,
sysmetricSmoothing: (document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value,
// HTTP value source
httpEndpoint: (document.getElementById('value-source-http-endpoint') as HTMLSelectElement | null)?.value || '',
httpJsonPath: (document.getElementById('value-source-http-json-path') as HTMLInputElement | null)?.value || '',
httpInterval: (document.getElementById('value-source-http-interval') as HTMLInputElement | null)?.value || '',
httpMin: (document.getElementById('value-source-http-min') as HTMLInputElement | null)?.value || '',
httpMax: (document.getElementById('value-source-http-max') as HTMLInputElement | null)?.value || '',
httpSmoothing: (document.getElementById('value-source-http-smoothing') as HTMLInputElement | null)?.value || '',
};
}
}
@@ -168,13 +178,17 @@ function _autoGenerateVSName() {
} else if (type === 'game_event') {
const eventType = (document.getElementById('value-source-game-event-type') as HTMLSelectElement)?.value;
if (eventType) detail = eventType;
} else if (type === 'http') {
const sel = document.getElementById('value-source-http-endpoint') as HTMLSelectElement | null;
const name = sel?.selectedOptions[0]?.textContent?.trim();
if (name) detail = name;
}
(document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}
/* ── Icon-grid type selector ──────────────────────────────────── */
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event'];
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event', 'http'];
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];
@@ -446,6 +460,31 @@ function _ensureVSTypeIconSelect() {
_vsTypeIconSelect = new IconSelect({ target: sel, items: _buildVSTypeItems(), columns: 2 } as any);
}
/* ── HTTP endpoint picker (EntitySelect over httpEndpointsCache) ── */
function _populateVSHTTPEndpointDropdown(selectedId: string = '') {
const sel = document.getElementById('value-source-http-endpoint') as HTMLSelectElement;
if (!sel) return;
const endpoints = _cachedHTTPEndpoints || [];
const prev = selectedId || sel.value;
sel.innerHTML = `<option value="">—</option>` +
endpoints.map(e => `<option value="${e.id}"${e.id === prev ? ' selected' : ''}>${escapeHtml(e.name)}</option>`).join('');
sel.value = prev || '';
if (_vsHTTPEndpointEntitySelect) _vsHTTPEndpointEntitySelect.destroy();
_vsHTTPEndpointEntitySelect = new EntitySelect({
target: sel,
getItems: () => (_cachedHTTPEndpoints || []).map(e => ({
value: e.id,
label: e.name,
icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`,
desc: `${e.method} ${e.url}`,
})),
placeholder: t('palette.search'),
onChange: () => _autoGenerateVSName(),
} as any);
}
// ── Modal ─────────────────────────────────────────────────────
export async function showValueSourceModal(editData: any, presetType: any = null) {
@@ -586,6 +625,14 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_setSlider('value-source-ge-smoothing', editData.smoothing ?? 0);
_setSlider('value-source-ge-default', editData.default_value ?? 0.5);
_setSlider('value-source-ge-timeout', editData.timeout ?? 5.0);
} else if (editData.source_type === 'http') {
await httpEndpointsCache.fetch();
_populateVSHTTPEndpointDropdown(editData.http_endpoint_id || '');
(document.getElementById('value-source-http-json-path') as HTMLInputElement).value = editData.json_path || '';
(document.getElementById('value-source-http-interval') as HTMLInputElement).value = String(editData.interval_s ?? 60);
(document.getElementById('value-source-http-min') as HTMLInputElement).value = String(editData.min_value ?? 0);
(document.getElementById('value-source-http-max') as HTMLInputElement).value = String(editData.max_value ?? 100);
_setSlider('value-source-http-smoothing', editData.smoothing ?? 0);
}
} else {
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
@@ -650,6 +697,16 @@ export async function showValueSourceModal(editData: any, presetType: any = null
(document.getElementById('value-source-sensor-label') as HTMLInputElement).value = '';
_setSlider('value-source-poll-interval', 1.0);
_setSlider('value-source-sysmetric-smoothing', 0);
// HTTP value source defaults
const httpJsonPath = document.getElementById('value-source-http-json-path') as HTMLInputElement | null;
if (httpJsonPath) httpJsonPath.value = '';
const httpInterval = document.getElementById('value-source-http-interval') as HTMLInputElement | null;
if (httpInterval) httpInterval.value = '60';
const httpMin = document.getElementById('value-source-http-min') as HTMLInputElement | null;
if (httpMin) httpMin.value = '0';
const httpMax = document.getElementById('value-source-http-max') as HTMLInputElement | null;
if (httpMax) httpMax.value = '100';
_setSlider('value-source-http-smoothing', 0);
_autoGenerateVSName();
}
@@ -699,6 +756,13 @@ export function onValueSourceTypeChange() {
if (type === 'game_event') {
_populateVSGameIntegrationDropdown('');
}
const httpSec = document.getElementById('value-source-http-section') as HTMLElement | null;
if (httpSec) httpSec.style.display = type === 'http' ? '' : 'none';
if (type === 'http') {
// Refresh endpoint list lazily — value-source modal can be opened
// before the integrations tab has been visited.
httpEndpointsCache.fetch().then(() => _populateVSHTTPEndpointDropdown(''));
}
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
@@ -755,6 +819,8 @@ function _syncDaylightVSSpeedVisibility() {
export async function saveValueSource() {
const id = (document.getElementById('value-source-id') as HTMLInputElement).value;
if (valueSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('value-source-name') as HTMLInputElement).value.trim();
const sourceType = (document.getElementById('value-source-type') as HTMLSelectElement).value;
const description = (document.getElementById('value-source-description') as HTMLInputElement).value.trim() || null;
@@ -879,6 +945,23 @@ export async function saveValueSource() {
errorEl.style.display = '';
return;
}
} else if (sourceType === 'http') {
payload.http_endpoint_id = (document.getElementById('value-source-http-endpoint') as HTMLSelectElement).value;
payload.json_path = (document.getElementById('value-source-http-json-path') as HTMLInputElement).value.trim();
payload.interval_s = parseInt((document.getElementById('value-source-http-interval') as HTMLInputElement).value, 10) || 60;
payload.min_value = parseFloat((document.getElementById('value-source-http-min') as HTMLInputElement).value) || 0;
payload.max_value = parseFloat((document.getElementById('value-source-http-max') as HTMLInputElement).value) || 100;
payload.smoothing = parseFloat((document.getElementById('value-source-http-smoothing') as HTMLInputElement).value) || 0;
if (!payload.http_endpoint_id) {
errorEl.textContent = t('value_source.http.endpoint_required');
errorEl.style.display = '';
return;
}
if (payload.interval_s < 1) {
errorEl.textContent = t('value_source.http.interval_invalid');
errorEl.style.display = '';
return;
}
}
try {
@@ -594,6 +594,55 @@ class SystemMetricsValueSource(ValueSource):
)
@dataclass
class HTTPValueSource(ValueSource):
"""Value source that periodically polls an HTTP endpoint and extracts
a value from the response.
The endpoint owns "where + how to reach it" (URL, auth, headers);
this source owns "what to extract + how often + how to normalize".
Exposes two accessors at runtime:
- ``get_value()`` returns a normalized float in [0, 1] suitable for
driving brightness/color modulators. Non-numeric responses or
missing paths yield 0.0.
- ``get_raw_value()`` returns the raw extracted value (str / int /
float / bool / None) for consumers that need the un-normalized
data — automation rules compare against this.
"""
http_endpoint_id: str = "" # references an HTTPEndpoint
json_path: str = "" # dot-path; empty = use raw body text
interval_s: int = 60 # polling cadence
min_value: float = 0.0 # raw value → 0.0
max_value: float = 100.0 # raw value → 1.0
smoothing: float = 0.0 # EMA smoothing on the normalized output
def to_dict(self) -> dict:
d = super().to_dict()
d["http_endpoint_id"] = self.http_endpoint_id
d["json_path"] = self.json_path
d["interval_s"] = self.interval_s
d["min_value"] = self.min_value
d["max_value"] = self.max_value
d["smoothing"] = self.smoothing
return d
@classmethod
def from_dict(cls, data: dict) -> "HTTPValueSource":
common = _parse_common_fields(data)
return cls(
**common,
source_type="http",
http_endpoint_id=data.get("http_endpoint_id") or "",
json_path=data.get("json_path") or "",
interval_s=int(data.get("interval_s") or 60),
min_value=float(data.get("min_value") or 0.0),
max_value=float(data.get("max_value") if data.get("max_value") is not None else 100.0),
smoothing=float(data.get("smoothing") or 0.0),
)
# -- Source type registry --
# Maps source_type string to its subclass for factory dispatch.
_VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
@@ -611,4 +660,5 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
"css_extract": CSSExtractValueSource,
"system_metrics": SystemMetricsValueSource,
"game_event": GameEventValueSource,
"http": HTTPValueSource,
}
@@ -61,6 +61,7 @@
<option value="gradient_map" data-i18n="value_source.type.gradient_map">Gradient Map</option>
<option value="css_extract" data-i18n="value_source.type.css_extract">Strip Extract</option>
<option value="system_metrics" data-i18n="value_source.type.system_metrics">System Metrics</option>
<option value="http" data-i18n="value_source.type.http">HTTP Poll</option>
</select>
</div>
@@ -637,6 +638,63 @@
</div>
</div>
<!-- HTTP value source fields -->
<div id="value-source-http-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-http-endpoint" data-i18n="value_source.http.endpoint">HTTP Endpoint:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.http.endpoint.hint">Pick a saved endpoint from the HTTP integrations tab.</small>
<select id="value-source-http-endpoint">
<option value=""></option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-http-json-path" data-i18n="value_source.http.json_path">JSON Path:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.http.json_path.hint">Empty = use raw response body. Use dotted/indexed path, e.g. MediaContainer.Metadata[0].title</small>
<input type="text" id="value-source-http-json-path" placeholder="MediaContainer.Metadata[0].title">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-http-interval" data-i18n="value_source.http.interval">Interval (s):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.http.interval.hint">Polling cadence in seconds. Multiple value sources can share one endpoint at different intervals.</small>
<input type="number" id="value-source-http-interval" min="1" step="1" value="60">
</div>
<details class="form-collapse">
<summary data-i18n="value_source.http.modulator.summary">Modulator mapping (optional)</summary>
<div class="form-collapse-body">
<small class="input-hint" data-i18n="value_source.http.modulator.hint">Only used when this source drives brightness or color. Automation rules read the raw extracted value and ignore these settings.</small>
<div class="form-group">
<div class="label-row">
<label for="value-source-http-min" data-i18n="value_source.http.min_value">Min Value:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.http.min_value.hint">Raw extracted value that maps to output 0.0 (for normalisation).</small>
<input type="number" id="value-source-http-min" step="any" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-http-max" data-i18n="value_source.http.max_value">Max Value:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.http.max_value.hint">Raw extracted value that maps to output 1.0 (for normalisation).</small>
<input type="number" id="value-source-http-max" step="any" value="100">
</div>
<div class="form-group">
<label for="value-source-http-smoothing"><span data-i18n="value_source.smoothing">Smoothing:</span> <span id="value-source-http-smoothing-display">0</span></label>
<input type="range" id="value-source-http-smoothing" min="0" max="0.99" step="0.01" value="0"
oninput="document.getElementById('value-source-http-smoothing-display').textContent = this.value">
</div>
</div>
</details>
</div>
<!-- Shared adaptive output range (shown for adaptive and daylight types) -->
<div id="value-source-adaptive-range-section" style="display:none">
<div class="form-group">