From b6713be390982f77fae741b209d616b34e2865f4 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 30 Mar 2026 18:22:58 +0300 Subject: [PATCH] feat: system_metrics value source type New value source that monitors host hardware via psutil/pynvml: cpu_load, cpu_temp, gpu_load, gpu_temp, ram_usage, disk_usage, network_rx, network_tx, battery_level, fan_speed. Each metric normalizes to 0.0-1.0 with configurable ranges, poll interval, EMA smoothing, and sensor_label for multi-sensor systems. Conditional editor fields show/hide based on selected metric. Also fixes: WS test crash when raw_value streams lack _min_ha attr, toast timer overlap on rapid calls, SW cache bump to v34. --- .../api/routes/value_sources.py | 21 +- .../api/schemas/value_sources.py | 40 +++ .../core/processing/value_stream.py | 229 ++++++++++++++++++ .../static/js/core/icon-paths.ts | 2 + .../wled_controller/static/js/core/icons.ts | 1 + .../static/js/features/value-sources.ts | 85 ++++++- server/src/wled_controller/static/js/types.ts | 28 ++- .../wled_controller/static/locales/en.json | 24 ++ server/src/wled_controller/static/sw.js | 2 +- .../wled_controller/storage/value_source.py | 63 +++++ .../storage/value_source_store.py | 53 ++++ .../templates/modals/value-source-editor.html | 80 ++++++ 12 files changed, 618 insertions(+), 10 deletions(-) diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py index 285a037..b3b803a 100644 --- a/server/src/wled_controller/api/routes/value_sources.py +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -25,6 +25,7 @@ from wled_controller.api.schemas.value_sources import ( HAEntityValueSourceResponse, StaticColorValueSourceResponse, StaticValueSourceResponse, + SystemMetricsValueSourceResponse, ValueSourceCreate, ValueSourceListResponse, ValueSourceResponse, @@ -42,6 +43,7 @@ from wled_controller.storage.value_source import ( HAEntityValueSource, StaticColorValueSource, StaticValueSource, + SystemMetricsValueSource, ValueSource, ) from wled_controller.storage.value_source_store import ValueSourceStore @@ -171,6 +173,22 @@ _RESPONSE_MAP = { led_start=s.led_start, led_end=s.led_end, ), + SystemMetricsValueSource: lambda s: SystemMetricsValueSourceResponse( + id=s.id, + name=s.name, + description=s.description, + tags=s.tags, + created_at=s.created_at, + updated_at=s.updated_at, + metric=s.metric, + min_value=s.min_value, + max_value=s.max_value, + max_rate=s.max_rate, + disk_path=s.disk_path, + sensor_label=s.sensor_label, + poll_interval=s.poll_interval, + smoothing=s.smoothing, + ), } @@ -403,7 +421,8 @@ async def test_value_source_ws( raw = stream.get_raw_value() if raw is not None: msg["raw_value"] = round(raw, 4) - msg["raw_range"] = [stream._min_ha, stream._max_ha] + if hasattr(stream, "_min_ha"): + msg["raw_range"] = [stream._min_ha, stream._max_ha] await websocket.send_json(msg) await asyncio.sleep(0.05) except WebSocketDisconnect: diff --git a/server/src/wled_controller/api/schemas/value_sources.py b/server/src/wled_controller/api/schemas/value_sources.py index b3f8c45..cc2c560 100644 --- a/server/src/wled_controller/api/schemas/value_sources.py +++ b/server/src/wled_controller/api/schemas/value_sources.py @@ -124,6 +124,19 @@ class CSSExtractValueSourceResponse(_ValueSourceResponseBase): led_end: int = Field(description="End of LED range (-1 = whole strip)") +class SystemMetricsValueSourceResponse(_ValueSourceResponseBase): + source_type: Literal["system_metrics"] = "system_metrics" + return_type: Literal["float"] = "float" + metric: str = Field(description="System metric to monitor") + min_value: float = Field(description="Min value for range-based metrics") + max_value: float = Field(description="Max value for range-based metrics") + max_rate: float = Field(description="Max rate in bytes/sec for network metrics") + disk_path: str = Field(description="Disk path for disk_usage metric") + sensor_label: str = Field(description="Sensor label for cpu_temp/fan_speed") + poll_interval: float = Field(description="Seconds between reads") + smoothing: float = Field(description="EMA smoothing factor (0.0-1.0)") + + ValueSourceResponse = Annotated[ Union[ Annotated[StaticValueSourceResponse, Tag("static")], @@ -138,6 +151,7 @@ ValueSourceResponse = Annotated[ Annotated[HAEntityValueSourceResponse, Tag("ha_entity")], Annotated[GradientMapValueSourceResponse, Tag("gradient_map")], Annotated[CSSExtractValueSourceResponse, Tag("css_extract")], + Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")], ], Discriminator("source_type"), ] @@ -252,6 +266,18 @@ class CSSExtractValueSourceCreate(_ValueSourceCreateBase): led_end: int = Field(-1, description="End of LED range (-1 = whole strip)") +class SystemMetricsValueSourceCreate(_ValueSourceCreateBase): + source_type: Literal["system_metrics"] = "system_metrics" + metric: str = Field("cpu_load", description="System metric to monitor") + min_value: float = Field(0.0, description="Min value for normalization") + max_value: float = Field(100.0, description="Max value for normalization") + max_rate: float = Field(125_000_000.0, description="Max rate bytes/sec for network") + disk_path: str = Field("", description="Disk path for disk_usage") + sensor_label: str = Field("", description="Sensor label for cpu_temp/fan_speed") + poll_interval: float = Field(1.0, description="Poll interval in seconds", ge=0.1, le=60.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")], @@ -266,6 +292,7 @@ ValueSourceCreate = Annotated[ Annotated[HAEntityValueSourceCreate, Tag("ha_entity")], Annotated[GradientMapValueSourceCreate, Tag("gradient_map")], Annotated[CSSExtractValueSourceCreate, Tag("css_extract")], + Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")], ], Discriminator("source_type"), ] @@ -374,6 +401,18 @@ class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase): led_end: Optional[int] = Field(None, description="LED range end") +class SystemMetricsValueSourceUpdate(_ValueSourceUpdateBase): + source_type: Literal["system_metrics"] = "system_metrics" + metric: Optional[str] = Field(None, description="System metric") + min_value: Optional[float] = Field(None, description="Min value") + max_value: Optional[float] = Field(None, description="Max value") + max_rate: Optional[float] = Field(None, description="Max rate bytes/sec") + disk_path: Optional[str] = Field(None, description="Disk path") + sensor_label: Optional[str] = Field(None, description="Sensor label") + poll_interval: Optional[float] = Field(None, description="Poll interval", ge=0.1, le=60.0) + smoothing: Optional[float] = Field(None, description="EMA smoothing", ge=0.0, le=1.0) + + ValueSourceUpdate = Annotated[ Union[ Annotated[StaticValueSourceUpdate, Tag("static")], @@ -388,6 +427,7 @@ ValueSourceUpdate = Annotated[ Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")], Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")], Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")], + Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")], ], Discriminator("source_type"), ] diff --git a/server/src/wled_controller/core/processing/value_stream.py b/server/src/wled_controller/core/processing/value_stream.py index 38b8a72..556739d 100644 --- a/server/src/wled_controller/core/processing/value_stream.py +++ b/server/src/wled_controller/core/processing/value_stream.py @@ -1154,6 +1154,222 @@ class CSSExtractValueStream(ValueStream): self._css_stream = None +# --------------------------------------------------------------------------- +# System metrics stream +# --------------------------------------------------------------------------- + + +class SystemMetricsValueStream(ValueStream): + """Reads system hardware metrics via psutil/pynvml. + + Normalizes readings to [0, 1], with optional EMA smoothing and + configurable poll interval. + """ + + def __init__( + self, + metric: str = "cpu_load", + min_value: float = 0.0, + max_value: float = 100.0, + max_rate: float = 125_000_000.0, + disk_path: str = "", + sensor_label: str = "", + poll_interval: float = 1.0, + smoothing: float = 0.0, + ): + self._metric = metric + self._min_val = min_value + self._max_val = max_value + self._max_rate = max_rate + self._disk_path = disk_path or ("/" if not disk_path else disk_path) + self._sensor_label = sensor_label + self._poll_interval = max(0.1, poll_interval) + self._smoothing = smoothing + self._prev_value: Optional[float] = None + self._raw_value: Optional[float] = None + self._last_poll: float = 0.0 + # Network delta tracking + self._prev_net_bytes: Optional[int] = None + self._prev_net_time: Optional[float] = None + # GPU unavailable flag (avoid repeated warnings) + self._gpu_unavailable = False + + def start(self) -> None: + import psutil + + # Prime cpu_percent so the first real call returns meaningful data + if self._metric == "cpu_load": + psutil.cpu_percent(interval=None) + # Prime network counters + if self._metric in ("network_rx", "network_tx"): + counters = psutil.net_io_counters() + if counters: + self._prev_net_bytes = ( + counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent + ) + self._prev_net_time = time.monotonic() + + def stop(self) -> None: + self._prev_value = None + self._raw_value = None + self._prev_net_bytes = None + self._prev_net_time = None + + def get_value(self) -> float: + now = time.monotonic() + if self._prev_value is not None and (now - self._last_poll) < self._poll_interval: + return self._prev_value + + self._last_poll = now + raw = self._read_metric() + self._raw_value = raw + + # Normalize + normalized = self._normalize(raw) + + # EMA smoothing + if self._smoothing > 0.0 and self._prev_value is not None: + normalized = self._smoothing * self._prev_value + (1.0 - self._smoothing) * normalized + + self._prev_value = normalized + return normalized + + def get_raw_value(self) -> Optional[float]: + """Return the last raw metric value before normalization.""" + return self._raw_value + + def _normalize(self, raw: float) -> float: + """Normalize raw value to [0, 1].""" + if self._metric in ("cpu_load", "ram_usage", "gpu_load", "battery_level", "disk_usage"): + return max(0.0, min(1.0, raw / 100.0)) + elif self._metric in ("cpu_temp", "gpu_temp", "fan_speed"): + rng = self._max_val - self._min_val + if abs(rng) < 1e-9: + return 0.5 + return max(0.0, min(1.0, (raw - self._min_val) / rng)) + elif self._metric in ("network_rx", "network_tx"): + if self._max_rate <= 0: + return 0.5 + return max(0.0, min(1.0, raw / self._max_rate)) + return 0.0 + + def _read_metric(self) -> float: + """Read the raw metric value from the system.""" + import psutil + + try: + if self._metric == "cpu_load": + return psutil.cpu_percent(interval=None) + elif self._metric == "ram_usage": + return psutil.virtual_memory().percent + elif self._metric == "disk_usage": + return psutil.disk_usage(self._disk_path).percent + elif self._metric == "battery_level": + bat = psutil.sensors_battery() + return bat.percent if bat else 0.0 + elif self._metric == "cpu_temp": + return self._read_cpu_temp() + elif self._metric == "fan_speed": + return self._read_fan_speed() + elif self._metric in ("gpu_load", "gpu_temp"): + return self._read_gpu_metric() + elif self._metric in ("network_rx", "network_tx"): + return self._read_network_rate() + except Exception as e: + logger.debug("SystemMetricsValueStream read error (%s): %s", self._metric, e) + return self._raw_value if self._raw_value is not None else 0.0 + + def _read_cpu_temp(self) -> float: + import psutil + + temps = psutil.sensors_temperatures() + if not temps: + return 0.0 + # If sensor_label specified, try to find it + if self._sensor_label: + for group_name, entries in temps.items(): + for entry in entries: + if entry.label == self._sensor_label or group_name == self._sensor_label: + return entry.current + # Fallback: first available sensor + for entries in temps.values(): + if entries: + return entries[0].current + return 0.0 + + def _read_fan_speed(self) -> float: + import psutil + + fans = psutil.sensors_fans() + if not fans: + return 0.0 + if self._sensor_label: + for group_name, entries in fans.items(): + for entry in entries: + if entry.label == self._sensor_label or group_name == self._sensor_label: + return entry.current + # Fallback: first available fan + for entries in fans.values(): + if entries: + return entries[0].current + return 0.0 + + def _read_gpu_metric(self) -> float: + if self._gpu_unavailable: + return 0.0 + try: + from wled_controller.utils.gpu import nvml, nvml_available, nvml_handle + + if not nvml_available or nvml_handle is None: + self._gpu_unavailable = True + return 0.0 + if self._metric == "gpu_load": + util = nvml.nvmlDeviceGetUtilizationRates(nvml_handle) + return float(util.gpu) + else: # gpu_temp + return float(nvml.nvmlDeviceGetTemperature(nvml_handle, 0)) + except Exception as e: + logger.debug("GPU metric read error: %s", e) + self._gpu_unavailable = True + return 0.0 + + def _read_network_rate(self) -> float: + import psutil + + counters = psutil.net_io_counters() + if not counters: + return 0.0 + current_bytes = counters.bytes_recv if self._metric == "network_rx" else counters.bytes_sent + now = time.monotonic() + if self._prev_net_bytes is None or self._prev_net_time is None: + self._prev_net_bytes = current_bytes + self._prev_net_time = now + return 0.0 + dt = now - self._prev_net_time + if dt <= 0: + return 0.0 + # Cap delta time to avoid spikes after long gaps + dt = min(dt, self._poll_interval * 2) + rate = (current_bytes - self._prev_net_bytes) / dt + self._prev_net_bytes = current_bytes + self._prev_net_time = now + return max(0.0, rate) + + def update_source(self, source: "ValueSource") -> None: + from wled_controller.storage.value_source import SystemMetricsValueSource + + if not isinstance(source, SystemMetricsValueSource): + return + self._metric = source.metric + self._min_val = source.min_value + self._max_val = source.max_value + self._max_rate = source.max_rate + self._disk_path = source.disk_path or "/" + self._sensor_label = source.sensor_label + self._poll_interval = max(0.1, source.poll_interval) + self._smoothing = source.smoothing + + # --------------------------------------------------------------------------- # Manager # --------------------------------------------------------------------------- @@ -1264,6 +1480,7 @@ class ValueStreamManager: StaticColorValueSource, AnimatedColorValueSource, AdaptiveTimeColorValueSource, + SystemMetricsValueSource, ) if isinstance(source, StaticValueSource): @@ -1359,5 +1576,17 @@ class ValueStreamManager: css_stream_manager=self._css_stream_manager, ) + if isinstance(source, SystemMetricsValueSource): + return SystemMetricsValueStream( + metric=source.metric, + min_value=source.min_value, + max_value=source.max_value, + max_rate=source.max_rate, + disk_path=source.disk_path, + sensor_label=source.sensor_label, + poll_interval=source.poll_interval, + smoothing=source.smoothing, + ) + # Fallback return StaticValueStream(value=1.0) diff --git a/server/src/wled_controller/static/js/core/icon-paths.ts b/server/src/wled_controller/static/js/core/icon-paths.ts index 9ba532f..cccdca3 100644 --- a/server/src/wled_controller/static/js/core/icon-paths.ts +++ b/server/src/wled_controller/static/js/core/icon-paths.ts @@ -97,3 +97,5 @@ export const doorOpen = ''; export const droplets = ''; export const fan = ''; +export const hardDrive = ''; +export const batteryFull = ''; diff --git a/server/src/wled_controller/static/js/core/icons.ts b/server/src/wled_controller/static/js/core/icons.ts index 09e1899..f958b2a 100644 --- a/server/src/wled_controller/static/js/core/icons.ts +++ b/server/src/wled_controller/static/js/core/icons.ts @@ -38,6 +38,7 @@ const _valueSourceTypeIcons = { adaptive_time_color: _svg(P.clock), ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow), css_extract: _svg(P.droplets), + system_metrics: _svg(P.cpu), }; const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) }; const _deviceTypeIcons = { diff --git a/server/src/wled_controller/static/js/features/value-sources.ts b/server/src/wled_controller/static/js/features/value-sources.ts index 6a923e8..353575f 100644 --- a/server/src/wled_controller/static/js/features/value-sources.ts +++ b/server/src/wled_controller/static/js/features/value-sources.ts @@ -48,6 +48,7 @@ let _vsGradientEntitySelect: EntitySelect | null = null; let _vsCSSSourceEntitySelect: EntitySelect | null = null; let _vsGradientEasingIconSelect: IconSelect | null = null; let _vsBehaviorIconSelect: IconSelect | null = null; +let _vsMetricIconSelect: IconSelect | null = null; let _vsTagsInput: TagInput | null = null; class ValueSourceModal extends Modal { @@ -63,6 +64,7 @@ class ValueSourceModal extends Modal { if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; } if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; } if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; } + if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; } } snapshotValues() { @@ -97,6 +99,14 @@ class ValueSourceModal extends Modal { animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value, colorSchedule: JSON.stringify(_colorSchedulePoints), tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []), + metric: (document.getElementById('value-source-metric') as HTMLSelectElement).value, + sysmetricMin: (document.getElementById('value-source-sysmetric-min') as HTMLInputElement).value, + sysmetricMax: (document.getElementById('value-source-sysmetric-max') as HTMLInputElement).value, + maxRate: (document.getElementById('value-source-max-rate') as HTMLInputElement).value, + diskPath: (document.getElementById('value-source-disk-path') as HTMLInputElement).value, + 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, }; } } @@ -123,13 +133,16 @@ function _autoGenerateVSName() { const sel = document.getElementById('value-source-picture-source') as HTMLSelectElement; const name = sel?.selectedOptions[0]?.textContent?.trim(); if (name) detail = name; + } else if (type === 'system_metrics') { + const metric = (document.getElementById('value-source-metric') as HTMLSelectElement).value; + detail = t(`value_source.metric.${metric}`); } (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']; +const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics']; 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]; @@ -302,6 +315,39 @@ function _ensureBehaviorIconSelect() { _vsBehaviorIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any); } +function _ensureMetricIconSelect() { + const sel = document.getElementById('value-source-metric') as HTMLSelectElement | null; + if (!sel) return; + const items = [ + { value: 'cpu_load', icon: _icon(P.activity), label: t('value_source.metric.cpu_load') }, + { value: 'cpu_temp', icon: _icon(P.thermometer), label: t('value_source.metric.cpu_temp') }, + { value: 'gpu_load', icon: _icon(P.zap), label: t('value_source.metric.gpu_load') }, + { value: 'gpu_temp', icon: _icon(P.flame), label: t('value_source.metric.gpu_temp') }, + { value: 'ram_usage', icon: _icon(P.cpu), label: t('value_source.metric.ram_usage') }, + { value: 'disk_usage', icon: _icon(P.hardDrive), label: t('value_source.metric.disk_usage') }, + { value: 'network_rx', icon: _icon(P.download), label: t('value_source.metric.network_rx') }, + { value: 'network_tx', icon: _icon(P.send), label: t('value_source.metric.network_tx') }, + { value: 'battery_level', icon: _icon(P.batteryFull), label: t('value_source.metric.battery_level') }, + { value: 'fan_speed', icon: _icon(P.fan), label: t('value_source.metric.fan_speed') }, + ]; + if (_vsMetricIconSelect) { _vsMetricIconSelect.updateItems(items); return; } + _vsMetricIconSelect = new IconSelect({ target: sel, items, columns: 2, onChange: (v: string) => { _onMetricChange(v); _autoGenerateVSName(); } } as any); +} + +function _onMetricChange(metric: string) { + const rangeFields = document.getElementById('value-source-sysmetric-range') as HTMLElement | null; + const networkFields = document.getElementById('value-source-sysmetric-network') as HTMLElement | null; + const diskFields = document.getElementById('value-source-sysmetric-disk') as HTMLElement | null; + const sensorFields = document.getElementById('value-source-sysmetric-sensor') as HTMLElement | null; + const rangeMetrics = ['cpu_temp', 'gpu_temp', 'fan_speed']; + const networkMetrics = ['network_rx', 'network_tx']; + const sensorMetrics = ['cpu_temp', 'fan_speed']; + if (rangeFields) rangeFields.style.display = rangeMetrics.includes(metric) ? '' : 'none'; + if (networkFields) networkFields.style.display = networkMetrics.includes(metric) ? '' : 'none'; + if (diskFields) diskFields.style.display = metric === 'disk_usage' ? '' : 'none'; + if (sensorFields) sensorFields.style.display = sensorMetrics.includes(metric) ? '' : 'none'; +} + function _ensureVSTypeIconSelect() { const sel = document.getElementById('value-source-type'); if (!sel) return; @@ -427,6 +473,17 @@ export async function showValueSourceModal(editData: any, presetType: any = null _populateCSSSourceDropdown(editData.color_strip_source_id || ''); (document.getElementById('value-source-led-start') as HTMLInputElement).value = String(editData.led_start ?? 0); (document.getElementById('value-source-led-end') as HTMLInputElement).value = String(editData.led_end ?? -1); + } else if (editData.source_type === 'system_metrics') { + (document.getElementById('value-source-metric') as HTMLSelectElement).value = editData.metric || 'cpu_load'; + _ensureMetricIconSelect(); + (document.getElementById('value-source-sysmetric-min') as HTMLInputElement).value = String(editData.min_value ?? 0); + (document.getElementById('value-source-sysmetric-max') as HTMLInputElement).value = String(editData.max_value ?? 100); + (document.getElementById('value-source-max-rate') as HTMLInputElement).value = String(editData.max_rate ?? 125000000); + (document.getElementById('value-source-disk-path') as HTMLInputElement).value = editData.disk_path || ''; + (document.getElementById('value-source-sensor-label') as HTMLInputElement).value = editData.sensor_label || ''; + _setSlider('value-source-poll-interval', editData.poll_interval ?? 1.0); + _setSlider('value-source-sysmetric-smoothing', editData.smoothing ?? 0); + _onMetricChange(editData.metric || 'cpu_load'); } } else { (document.getElementById('value-source-name') as HTMLInputElement).value = ''; @@ -479,6 +536,15 @@ export async function showValueSourceModal(editData: any, presetType: any = null // CSS extract defaults (document.getElementById('value-source-led-start') as HTMLInputElement).value = '0'; (document.getElementById('value-source-led-end') as HTMLInputElement).value = '-1'; + // System metrics defaults + (document.getElementById('value-source-metric') as HTMLSelectElement).value = 'cpu_load'; + (document.getElementById('value-source-sysmetric-min') as HTMLInputElement).value = '0'; + (document.getElementById('value-source-sysmetric-max') as HTMLInputElement).value = '100'; + (document.getElementById('value-source-max-rate') as HTMLInputElement).value = '125000000'; + (document.getElementById('value-source-disk-path') as HTMLInputElement).value = ''; + (document.getElementById('value-source-sensor-label') as HTMLInputElement).value = ''; + _setSlider('value-source-poll-interval', 1.0); + _setSlider('value-source-sysmetric-smoothing', 0); _autoGenerateVSName(); } @@ -519,6 +585,11 @@ export function onValueSourceTypeChange() { (document.getElementById('value-source-ha-entity-section') as HTMLElement).style.display = type === 'ha_entity' ? '' : 'none'; (document.getElementById('value-source-gradient-map-section') as HTMLElement).style.display = type === 'gradient_map' ? '' : 'none'; (document.getElementById('value-source-css-extract-section') as HTMLElement).style.display = type === 'css_extract' ? '' : 'none'; + (document.getElementById('value-source-system-metrics-section') as HTMLElement).style.display = type === 'system_metrics' ? '' : 'none'; + if (type === 'system_metrics') { + _ensureMetricIconSelect(); + _onMetricChange((document.getElementById('value-source-metric') as HTMLSelectElement).value); + } (document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display = (type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none'; @@ -674,6 +745,15 @@ export async function saveValueSource() { errorEl.style.display = ''; return; } + } else if (sourceType === 'system_metrics') { + payload.metric = (document.getElementById('value-source-metric') as HTMLSelectElement).value; + payload.min_value = parseFloat((document.getElementById('value-source-sysmetric-min') as HTMLInputElement).value) || 0; + payload.max_value = parseFloat((document.getElementById('value-source-sysmetric-max') as HTMLInputElement).value) || 100; + payload.max_rate = parseFloat((document.getElementById('value-source-max-rate') as HTMLInputElement).value) || 125000000; + payload.disk_path = (document.getElementById('value-source-disk-path') as HTMLInputElement).value; + payload.sensor_label = (document.getElementById('value-source-sensor-label') as HTMLInputElement).value; + payload.poll_interval = parseFloat((document.getElementById('value-source-poll-interval') as HTMLInputElement).value) || 1.0; + payload.smoothing = parseFloat((document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value) || 0; } try { @@ -1179,6 +1259,9 @@ export function createValueSourceCard(src: ValueSource) { ${cssBadge} ${ICON_MOVE_VERTICAL} LED ${rangeLabel} `; + } else if (src.source_type === 'system_metrics') { + const metricLabel = t(`value_source.metric.${(src as any).metric}`) || (src as any).metric; + propsHtml = `${ICON_ACTIVITY} ${escapeHtml(metricLabel)}`; } return wrapCard({ diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 2aa154f..bb63968 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -436,6 +436,19 @@ export interface CSSExtractValueSource extends ValueSourceBase { led_end: number; } +export interface SystemMetricsValueSource extends ValueSourceBase { + source_type: 'system_metrics'; + return_type: 'float'; + metric: string; + min_value: number; + max_value: number; + max_rate: number; + disk_path: string; + sensor_label: string; + poll_interval: number; + smoothing: number; +} + export type ValueSource = | StaticValueSource | AnimatedValueSource @@ -448,7 +461,8 @@ export type ValueSource = | AdaptiveTimeColorValueSource | HAEntityValueSource | GradientMapValueSource - | CSSExtractValueSource; + | CSSExtractValueSource + | SystemMetricsValueSource; // ── Audio Source ─────────────────────────────────────────────── @@ -640,12 +654,12 @@ export interface AssetListResponse { // ── Automation ──────────────────────────────────────────────── -export type ConditionType = - | 'always' | 'application' | 'time_of_day' | 'system_idle' +export type RuleType = + | 'application' | 'time_of_day' | 'system_idle' | 'display_state' | 'mqtt' | 'webhook' | 'startup'; -export interface AutomationCondition { - condition_type: ConditionType; +export interface AutomationRule { + rule_type: RuleType; apps?: string[]; match_type?: string; start_time?: string; @@ -663,8 +677,8 @@ export interface Automation { id: string; name: string; enabled: boolean; - condition_logic: 'or' | 'and'; - conditions: AutomationCondition[]; + rule_logic: 'or' | 'and'; + rules: AutomationRule[]; scene_preset_id?: string; deactivation_mode: 'none' | 'revert' | 'fallback_scene'; deactivation_scene_preset_id?: string; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 405e077..bafc390 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1463,6 +1463,30 @@ "value_source.type.gradient_map.desc": "Maps numeric value through a color gradient", "value_source.type.css_extract": "Strip Extract", "value_source.type.css_extract.desc": "Extracts color from a color strip source", + "value_source.type.system_metrics": "System Metrics", + "value_source.type.system_metrics.desc": "Monitor CPU, GPU, RAM, disk, network, battery, or fan speed", + "value_source.metric": "Metric:", + "value_source.metric.hint": "System metric to monitor. Some metrics may be unavailable depending on hardware.", + "value_source.metric.cpu_load": "CPU Load", + "value_source.metric.cpu_temp": "CPU Temperature", + "value_source.metric.gpu_load": "GPU Load", + "value_source.metric.gpu_temp": "GPU Temperature", + "value_source.metric.ram_usage": "RAM Usage", + "value_source.metric.disk_usage": "Disk Usage", + "value_source.metric.network_rx": "Network RX", + "value_source.metric.network_tx": "Network TX", + "value_source.metric.battery_level": "Battery Level", + "value_source.metric.fan_speed": "Fan Speed", + "value_source.sysmetric.min": "Min Value:", + "value_source.sysmetric.max": "Max Value:", + "value_source.max_rate": "Max Rate (bytes/sec):", + "value_source.max_rate.hint": "Maximum expected network rate in bytes/sec. 125000000 = 1 Gbps.", + "value_source.disk_path": "Disk Path:", + "value_source.disk_path.hint": "Disk mount point or drive letter (e.g. / or C:\\)", + "value_source.sensor_label": "Sensor Label:", + "value_source.sensor_label.hint": "Optional sensor name to pick a specific sensor (empty = first available)", + "value_source.poll_interval": "Poll Interval:", + "value_source.poll_interval.hint": "Seconds between metric reads", "value_source.gradient_map.input": "Input Value Source", "value_source.gradient_map.gradient": "Gradient", "value_source.css_extract.source": "Color Strip Source", diff --git a/server/src/wled_controller/static/sw.js b/server/src/wled_controller/static/sw.js index c29f8d0..cf5a72c 100644 --- a/server/src/wled_controller/static/sw.js +++ b/server/src/wled_controller/static/sw.js @@ -7,7 +7,7 @@ * - Navigation: network-first with offline fallback */ -const CACHE_NAME = 'ledgrab-v33'; +const CACHE_NAME = 'ledgrab-v34'; // Only pre-cache static assets (no auth required). // Do NOT pre-cache '/' — it requires API key auth and would cache an error page. diff --git a/server/src/wled_controller/storage/value_source.py b/server/src/wled_controller/storage/value_source.py index 6ba143f..2e743c7 100644 --- a/server/src/wled_controller/storage/value_source.py +++ b/server/src/wled_controller/storage/value_source.py @@ -467,6 +467,68 @@ class CSSExtractValueSource(ValueSource): ) +VALID_SYSTEM_METRICS = ( + "cpu_load", + "cpu_temp", + "gpu_load", + "gpu_temp", + "ram_usage", + "disk_usage", + "network_rx", + "network_tx", + "battery_level", + "fan_speed", +) + + +@dataclass +class SystemMetricsValueSource(ValueSource): + """Value source that reads system hardware metrics. + + Reads CPU/GPU/RAM/disk/network/battery/fan metrics via psutil/pynvml, + normalizes to 0.0–1.0. + """ + + metric: str = "cpu_load" + min_value: float = 0.0 + max_value: float = 100.0 + max_rate: float = 125_000_000.0 # bytes/sec for network (default ~1Gbps) + disk_path: str = "" # for disk_usage (empty = root) + sensor_label: str = "" # for cpu_temp/fan_speed (empty = first available) + poll_interval: float = 1.0 # seconds between reads + smoothing: float = 0.0 # EMA smoothing factor + + def to_dict(self) -> dict: + d = super().to_dict() + d["metric"] = self.metric + d["min_value"] = self.min_value + d["max_value"] = self.max_value + d["max_rate"] = self.max_rate + d["disk_path"] = self.disk_path + d["sensor_label"] = self.sensor_label + d["poll_interval"] = self.poll_interval + d["smoothing"] = self.smoothing + return d + + @classmethod + def from_dict(cls, data: dict) -> "SystemMetricsValueSource": + common = _parse_common_fields(data) + return cls( + **common, + source_type="system_metrics", + metric=data.get("metric") or "cpu_load", + 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), + max_rate=float( + data.get("max_rate") if data.get("max_rate") is not None else 125_000_000.0 + ), + disk_path=data.get("disk_path") or "", + sensor_label=data.get("sensor_label") or "", + poll_interval=float(data.get("poll_interval") or 1.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]] = { @@ -482,4 +544,5 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = { "ha_entity": HAEntityValueSource, "gradient_map": GradientMapValueSource, "css_extract": CSSExtractValueSource, + "system_metrics": SystemMetricsValueSource, } diff --git a/server/src/wled_controller/storage/value_source_store.py b/server/src/wled_controller/storage/value_source_store.py index 4bd2670..8ce9d39 100644 --- a/server/src/wled_controller/storage/value_source_store.py +++ b/server/src/wled_controller/storage/value_source_store.py @@ -8,6 +8,7 @@ from wled_controller.storage.base_sqlite_store import BaseSqliteStore from wled_controller.storage.database import Database from wled_controller.storage.utils import resolve_ref from wled_controller.storage.value_source import ( + VALID_SYSTEM_METRICS, AdaptiveValueSource, AdaptiveTimeColorValueSource, AnimatedColorValueSource, @@ -19,6 +20,7 @@ from wled_controller.storage.value_source import ( HAEntityValueSource, StaticColorValueSource, StaticValueSource, + SystemMetricsValueSource, ValueSource, ) from wled_controller.utils import get_logger @@ -76,6 +78,11 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]): color_strip_source_id: Optional[str] = None, led_start: Optional[int] = None, led_end: Optional[int] = None, + metric: Optional[str] = None, + max_rate: Optional[float] = None, + disk_path: Optional[str] = None, + sensor_label: Optional[str] = None, + poll_interval: Optional[float] = None, ) -> ValueSource: _VALID = ( "static", @@ -90,6 +97,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]): "ha_entity", "gradient_map", "css_extract", + "system_metrics", ) if source_type not in _VALID: raise ValueError(f"Invalid source type: {source_type}") @@ -282,6 +290,27 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]): led_start=led_start if led_start is not None else 0, led_end=led_end if led_end is not None else -1, ) + elif source_type == "system_metrics": + m = metric or "cpu_load" + if m not in VALID_SYSTEM_METRICS: + raise ValueError(f"Invalid metric: {m}") + source = SystemMetricsValueSource( + id=sid, + name=name, + source_type="system_metrics", + created_at=now, + updated_at=now, + description=description, + tags=common_tags, + metric=m, + min_value=min_value if min_value is not None else 0.0, + max_value=max_value if max_value is not None else 100.0, + max_rate=max_rate if max_rate is not None else 125_000_000.0, + disk_path=disk_path or "", + sensor_label=sensor_label or "", + poll_interval=poll_interval if poll_interval is not None else 1.0, + smoothing=smoothing if smoothing is not None else 0.0, + ) self._items[sid] = source self._save_item(sid, source) @@ -323,6 +352,11 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]): color_strip_source_id: Optional[str] = None, led_start: Optional[int] = None, led_end: Optional[int] = None, + metric: Optional[str] = None, + max_rate: Optional[float] = None, + disk_path: Optional[str] = None, + sensor_label: Optional[str] = None, + poll_interval: Optional[float] = None, ) -> ValueSource: source = self.get(source_id) @@ -434,6 +468,25 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]): source.led_start = led_start if led_end is not None: source.led_end = led_end + elif isinstance(source, SystemMetricsValueSource): + if metric is not None: + if metric not in VALID_SYSTEM_METRICS: + raise ValueError(f"Invalid metric: {metric}") + source.metric = metric + if min_value is not None: + source.min_value = min_value + if max_value is not None: + source.max_value = max_value + if max_rate is not None: + source.max_rate = max_rate + if disk_path is not None: + source.disk_path = disk_path + if sensor_label is not None: + source.sensor_label = sensor_label + if poll_interval is not None: + source.poll_interval = poll_interval + if smoothing is not None: + source.smoothing = smoothing source.updated_at = datetime.now(timezone.utc) self._save_item(source_id, source) diff --git a/server/src/wled_controller/templates/modals/value-source-editor.html b/server/src/wled_controller/templates/modals/value-source-editor.html index 638f856..b780a4b 100644 --- a/server/src/wled_controller/templates/modals/value-source-editor.html +++ b/server/src/wled_controller/templates/modals/value-source-editor.html @@ -42,6 +42,7 @@ + @@ -455,6 +456,85 @@ + + +