diff --git a/server/src/ledgrab/api/routes/value_sources.py b/server/src/ledgrab/api/routes/value_sources.py index 95530a2..b305fcb 100644 --- a/server/src/ledgrab/api/routes/value_sources.py +++ b/server/src/ledgrab/api/routes/value_sources.py @@ -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, + ), } diff --git a/server/src/ledgrab/api/schemas/value_sources.py b/server/src/ledgrab/api/schemas/value_sources.py index e0db9bf..470d8b9 100644 --- a/server/src/ledgrab/api/schemas/value_sources.py +++ b/server/src/ledgrab/api/schemas/value_sources.py @@ -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"), ] diff --git a/server/src/ledgrab/static/js/features/value-sources.ts b/server/src/ledgrab/static/js/features/value-sources.ts index 853633b..63457d3 100644 --- a/server/src/ledgrab/static/js/features/value-sources.ts +++ b/server/src/ledgrab/static/js/features/value-sources.ts @@ -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 = `` + + endpoints.map(e => ``).join(''); + sel.value = prev || ''; + + if (_vsHTTPEndpointEntitySelect) _vsHTTPEndpointEntitySelect.destroy(); + _vsHTTPEndpointEntitySelect = new EntitySelect({ + target: sel, + getItems: () => (_cachedHTTPEndpoints || []).map(e => ({ + value: e.id, + label: e.name, + icon: `${P.globe}`, + 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 { diff --git a/server/src/ledgrab/storage/value_source.py b/server/src/ledgrab/storage/value_source.py index 10d92ce..00296e6 100644 --- a/server/src/ledgrab/storage/value_source.py +++ b/server/src/ledgrab/storage/value_source.py @@ -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, } diff --git a/server/src/ledgrab/templates/modals/value-source-editor.html b/server/src/ledgrab/templates/modals/value-source-editor.html index 662f8bd..334a2c4 100644 --- a/server/src/ledgrab/templates/modals/value-source-editor.html +++ b/server/src/ledgrab/templates/modals/value-source-editor.html @@ -61,6 +61,7 @@ + @@ -637,6 +638,63 @@ + + +