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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+