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:
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.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,
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user