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