feat: system_metrics value source type
Lint & Test / test (push) Successful in 1m32s

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.
This commit is contained in:
2026-03-30 18:22:58 +03:00
parent db5008aaeb
commit b6713be390
12 changed files with 618 additions and 10 deletions
@@ -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:
@@ -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"),
]
@@ -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)
@@ -97,3 +97,5 @@ export const doorOpen = '<path d="M13 4h3a2 2 0 0 1 2 2v14"/><path d="M2 20h
export const toggleRight = '<rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/>';
export const droplets = '<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z"/><path d="M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97"/>';
export const fan = '<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/>';
export const hardDrive = '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>';
export const batteryFull = '<rect width="16" height="10" x="2" y="7" rx="2" ry="2"/><line x1="22" x2="22" y1="11" y2="13"/><line x1="6" x2="6" y1="11" y2="13"/><line x1="10" x2="10" y1="11" y2="13"/><line x1="14" x2="14" y1="11" y2="13"/>';
@@ -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 = {
@@ -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}
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} LED ${rangeLabel}</span>
`;
} else if (src.source_type === 'system_metrics') {
const metricLabel = t(`value_source.metric.${(src as any).metric}`) || (src as any).metric;
propsHtml = `<span class="stream-card-prop">${ICON_ACTIVITY} ${escapeHtml(metricLabel)}</span>`;
}
return wrapCard({
+21 -7
View File
@@ -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;
@@ -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",
+1 -1
View File
@@ -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.
@@ -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.01.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,
}
@@ -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)
@@ -42,6 +42,7 @@
<option value="ha_entity" data-i18n="value_source.type.ha_entity">HA Entity</option>
<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>
</select>
</div>
@@ -455,6 +456,85 @@
</div>
</div>
<!-- System Metrics fields -->
<div id="value-source-system-metrics-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-metric" data-i18n="value_source.metric">Metric:</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.metric.hint">System metric to monitor. Some metrics may be unavailable depending on hardware.</small>
<select id="value-source-metric">
<option value="cpu_load">CPU Load</option>
<option value="cpu_temp">CPU Temperature</option>
<option value="gpu_load">GPU Load</option>
<option value="gpu_temp">GPU Temperature</option>
<option value="ram_usage">RAM Usage</option>
<option value="disk_usage">Disk Usage</option>
<option value="network_rx">Network RX</option>
<option value="network_tx">Network TX</option>
<option value="battery_level">Battery Level</option>
<option value="fan_speed">Fan Speed</option>
</select>
</div>
<div id="value-source-sysmetric-range" style="display:none">
<div class="form-group">
<label for="value-source-sysmetric-min"><span data-i18n="value_source.sysmetric.min">Min Value:</span></label>
<input type="number" id="value-source-sysmetric-min" step="any" value="0">
</div>
<div class="form-group">
<label for="value-source-sysmetric-max"><span data-i18n="value_source.sysmetric.max">Max Value:</span></label>
<input type="number" id="value-source-sysmetric-max" step="any" value="100">
</div>
</div>
<div id="value-source-sysmetric-network" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-max-rate" data-i18n="value_source.max_rate">Max Rate (bytes/sec):</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.max_rate.hint">Maximum expected network rate in bytes/sec. 125000000 = 1 Gbps.</small>
<input type="number" id="value-source-max-rate" min="1" step="1" value="125000000">
</div>
</div>
<div id="value-source-sysmetric-disk" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-disk-path" data-i18n="value_source.disk_path">Disk 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.disk_path.hint">Disk mount point or drive letter (e.g. / or C:\)</small>
<input type="text" id="value-source-disk-path" placeholder="/" value="">
</div>
</div>
<div id="value-source-sysmetric-sensor" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-sensor-label" data-i18n="value_source.sensor_label">Sensor Label:</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.sensor_label.hint">Optional sensor name to pick a specific sensor (empty = first available)</small>
<input type="text" id="value-source-sensor-label" placeholder="" value="">
</div>
</div>
<div class="form-group">
<label for="value-source-poll-interval"><span data-i18n="value_source.poll_interval">Poll Interval:</span> <span id="value-source-poll-interval-display">1.0</span>s</label>
<input type="range" id="value-source-poll-interval" min="0.5" max="10" step="0.5" value="1"
oninput="document.getElementById('value-source-poll-interval-display').textContent = this.value">
</div>
<div class="form-group">
<label for="value-source-sysmetric-smoothing"><span data-i18n="value_source.smoothing">Smoothing:</span> <span id="value-source-sysmetric-smoothing-display">0</span></label>
<input type="range" id="value-source-sysmetric-smoothing" min="0" max="0.99" step="0.01" value="0"
oninput="document.getElementById('value-source-sysmetric-smoothing-display').textContent = this.value">
</div>
</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">