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,
|
HAEntityValueSourceResponse,
|
||||||
StaticColorValueSourceResponse,
|
StaticColorValueSourceResponse,
|
||||||
StaticValueSourceResponse,
|
StaticValueSourceResponse,
|
||||||
|
SystemMetricsValueSourceResponse,
|
||||||
ValueSourceCreate,
|
ValueSourceCreate,
|
||||||
ValueSourceListResponse,
|
ValueSourceListResponse,
|
||||||
ValueSourceResponse,
|
ValueSourceResponse,
|
||||||
@@ -42,6 +43,7 @@ from wled_controller.storage.value_source import (
|
|||||||
HAEntityValueSource,
|
HAEntityValueSource,
|
||||||
StaticColorValueSource,
|
StaticColorValueSource,
|
||||||
StaticValueSource,
|
StaticValueSource,
|
||||||
|
SystemMetricsValueSource,
|
||||||
ValueSource,
|
ValueSource,
|
||||||
)
|
)
|
||||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||||
@@ -171,6 +173,22 @@ _RESPONSE_MAP = {
|
|||||||
led_start=s.led_start,
|
led_start=s.led_start,
|
||||||
led_end=s.led_end,
|
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()
|
raw = stream.get_raw_value()
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
msg["raw_value"] = round(raw, 4)
|
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 websocket.send_json(msg)
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
|||||||
@@ -124,6 +124,19 @@ class CSSExtractValueSourceResponse(_ValueSourceResponseBase):
|
|||||||
led_end: int = Field(description="End of LED range (-1 = whole strip)")
|
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[
|
ValueSourceResponse = Annotated[
|
||||||
Union[
|
Union[
|
||||||
Annotated[StaticValueSourceResponse, Tag("static")],
|
Annotated[StaticValueSourceResponse, Tag("static")],
|
||||||
@@ -138,6 +151,7 @@ ValueSourceResponse = Annotated[
|
|||||||
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
|
Annotated[HAEntityValueSourceResponse, Tag("ha_entity")],
|
||||||
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
|
Annotated[GradientMapValueSourceResponse, Tag("gradient_map")],
|
||||||
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
|
Annotated[CSSExtractValueSourceResponse, Tag("css_extract")],
|
||||||
|
Annotated[SystemMetricsValueSourceResponse, Tag("system_metrics")],
|
||||||
],
|
],
|
||||||
Discriminator("source_type"),
|
Discriminator("source_type"),
|
||||||
]
|
]
|
||||||
@@ -252,6 +266,18 @@ class CSSExtractValueSourceCreate(_ValueSourceCreateBase):
|
|||||||
led_end: int = Field(-1, description="End of LED range (-1 = whole strip)")
|
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[
|
ValueSourceCreate = Annotated[
|
||||||
Union[
|
Union[
|
||||||
Annotated[StaticValueSourceCreate, Tag("static")],
|
Annotated[StaticValueSourceCreate, Tag("static")],
|
||||||
@@ -266,6 +292,7 @@ ValueSourceCreate = Annotated[
|
|||||||
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
|
Annotated[HAEntityValueSourceCreate, Tag("ha_entity")],
|
||||||
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
|
Annotated[GradientMapValueSourceCreate, Tag("gradient_map")],
|
||||||
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
|
Annotated[CSSExtractValueSourceCreate, Tag("css_extract")],
|
||||||
|
Annotated[SystemMetricsValueSourceCreate, Tag("system_metrics")],
|
||||||
],
|
],
|
||||||
Discriminator("source_type"),
|
Discriminator("source_type"),
|
||||||
]
|
]
|
||||||
@@ -374,6 +401,18 @@ class CSSExtractValueSourceUpdate(_ValueSourceUpdateBase):
|
|||||||
led_end: Optional[int] = Field(None, description="LED range end")
|
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[
|
ValueSourceUpdate = Annotated[
|
||||||
Union[
|
Union[
|
||||||
Annotated[StaticValueSourceUpdate, Tag("static")],
|
Annotated[StaticValueSourceUpdate, Tag("static")],
|
||||||
@@ -388,6 +427,7 @@ ValueSourceUpdate = Annotated[
|
|||||||
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
|
Annotated[HAEntityValueSourceUpdate, Tag("ha_entity")],
|
||||||
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
|
Annotated[GradientMapValueSourceUpdate, Tag("gradient_map")],
|
||||||
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
|
Annotated[CSSExtractValueSourceUpdate, Tag("css_extract")],
|
||||||
|
Annotated[SystemMetricsValueSourceUpdate, Tag("system_metrics")],
|
||||||
],
|
],
|
||||||
Discriminator("source_type"),
|
Discriminator("source_type"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1154,6 +1154,222 @@ class CSSExtractValueStream(ValueStream):
|
|||||||
self._css_stream = None
|
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
|
# Manager
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1264,6 +1480,7 @@ class ValueStreamManager:
|
|||||||
StaticColorValueSource,
|
StaticColorValueSource,
|
||||||
AnimatedColorValueSource,
|
AnimatedColorValueSource,
|
||||||
AdaptiveTimeColorValueSource,
|
AdaptiveTimeColorValueSource,
|
||||||
|
SystemMetricsValueSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(source, StaticValueSource):
|
if isinstance(source, StaticValueSource):
|
||||||
@@ -1359,5 +1576,17 @@ class ValueStreamManager:
|
|||||||
css_stream_manager=self._css_stream_manager,
|
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
|
# Fallback
|
||||||
return StaticValueStream(value=1.0)
|
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 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 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 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),
|
adaptive_time_color: _svg(P.clock),
|
||||||
ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow),
|
ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow),
|
||||||
css_extract: _svg(P.droplets),
|
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 _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) };
|
||||||
const _deviceTypeIcons = {
|
const _deviceTypeIcons = {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ let _vsGradientEntitySelect: EntitySelect | null = null;
|
|||||||
let _vsCSSSourceEntitySelect: EntitySelect | null = null;
|
let _vsCSSSourceEntitySelect: EntitySelect | null = null;
|
||||||
let _vsGradientEasingIconSelect: IconSelect | null = null;
|
let _vsGradientEasingIconSelect: IconSelect | null = null;
|
||||||
let _vsBehaviorIconSelect: IconSelect | null = null;
|
let _vsBehaviorIconSelect: IconSelect | null = null;
|
||||||
|
let _vsMetricIconSelect: IconSelect | null = null;
|
||||||
let _vsTagsInput: TagInput | null = null;
|
let _vsTagsInput: TagInput | null = null;
|
||||||
|
|
||||||
class ValueSourceModal extends Modal {
|
class ValueSourceModal extends Modal {
|
||||||
@@ -63,6 +64,7 @@ class ValueSourceModal extends Modal {
|
|||||||
if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; }
|
if (_vsCSSSourceEntitySelect) { _vsCSSSourceEntitySelect.destroy(); _vsCSSSourceEntitySelect = null; }
|
||||||
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
|
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
|
||||||
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
|
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
|
||||||
|
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotValues() {
|
snapshotValues() {
|
||||||
@@ -97,6 +99,14 @@ class ValueSourceModal extends Modal {
|
|||||||
animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value,
|
animatedColorEasing: (document.getElementById('value-source-animated-color-easing') as HTMLSelectElement).value,
|
||||||
colorSchedule: JSON.stringify(_colorSchedulePoints),
|
colorSchedule: JSON.stringify(_colorSchedulePoints),
|
||||||
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
|
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 sel = document.getElementById('value-source-picture-source') as HTMLSelectElement;
|
||||||
const name = sel?.selectedOptions[0]?.textContent?.trim();
|
const name = sel?.selectedOptions[0]?.textContent?.trim();
|
||||||
if (name) detail = name;
|
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;
|
(document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
/* ── 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_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];
|
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);
|
_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() {
|
function _ensureVSTypeIconSelect() {
|
||||||
const sel = document.getElementById('value-source-type');
|
const sel = document.getElementById('value-source-type');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -427,6 +473,17 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
|||||||
_populateCSSSourceDropdown(editData.color_strip_source_id || '');
|
_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-start') as HTMLInputElement).value = String(editData.led_start ?? 0);
|
||||||
(document.getElementById('value-source-led-end') as HTMLInputElement).value = String(editData.led_end ?? -1);
|
(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 {
|
} else {
|
||||||
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
|
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
|
||||||
@@ -479,6 +536,15 @@ export async function showValueSourceModal(editData: any, presetType: any = null
|
|||||||
// CSS extract defaults
|
// CSS extract defaults
|
||||||
(document.getElementById('value-source-led-start') as HTMLInputElement).value = '0';
|
(document.getElementById('value-source-led-start') as HTMLInputElement).value = '0';
|
||||||
(document.getElementById('value-source-led-end') as HTMLInputElement).value = '-1';
|
(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();
|
_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-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-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-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 =
|
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
|
||||||
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
|
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
|
||||||
|
|
||||||
@@ -674,6 +745,15 @@ export async function saveValueSource() {
|
|||||||
errorEl.style.display = '';
|
errorEl.style.display = '';
|
||||||
return;
|
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 {
|
try {
|
||||||
@@ -1179,6 +1259,9 @@ export function createValueSourceCard(src: ValueSource) {
|
|||||||
${cssBadge}
|
${cssBadge}
|
||||||
<span class="stream-card-prop">${ICON_MOVE_VERTICAL} LED ${rangeLabel}</span>
|
<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({
|
return wrapCard({
|
||||||
|
|||||||
@@ -436,6 +436,19 @@ export interface CSSExtractValueSource extends ValueSourceBase {
|
|||||||
led_end: number;
|
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 =
|
export type ValueSource =
|
||||||
| StaticValueSource
|
| StaticValueSource
|
||||||
| AnimatedValueSource
|
| AnimatedValueSource
|
||||||
@@ -448,7 +461,8 @@ export type ValueSource =
|
|||||||
| AdaptiveTimeColorValueSource
|
| AdaptiveTimeColorValueSource
|
||||||
| HAEntityValueSource
|
| HAEntityValueSource
|
||||||
| GradientMapValueSource
|
| GradientMapValueSource
|
||||||
| CSSExtractValueSource;
|
| CSSExtractValueSource
|
||||||
|
| SystemMetricsValueSource;
|
||||||
|
|
||||||
// ── Audio Source ───────────────────────────────────────────────
|
// ── Audio Source ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -640,12 +654,12 @@ export interface AssetListResponse {
|
|||||||
|
|
||||||
// ── Automation ────────────────────────────────────────────────
|
// ── Automation ────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ConditionType =
|
export type RuleType =
|
||||||
| 'always' | 'application' | 'time_of_day' | 'system_idle'
|
| 'application' | 'time_of_day' | 'system_idle'
|
||||||
| 'display_state' | 'mqtt' | 'webhook' | 'startup';
|
| 'display_state' | 'mqtt' | 'webhook' | 'startup';
|
||||||
|
|
||||||
export interface AutomationCondition {
|
export interface AutomationRule {
|
||||||
condition_type: ConditionType;
|
rule_type: RuleType;
|
||||||
apps?: string[];
|
apps?: string[];
|
||||||
match_type?: string;
|
match_type?: string;
|
||||||
start_time?: string;
|
start_time?: string;
|
||||||
@@ -663,8 +677,8 @@ export interface Automation {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
condition_logic: 'or' | 'and';
|
rule_logic: 'or' | 'and';
|
||||||
conditions: AutomationCondition[];
|
rules: AutomationRule[];
|
||||||
scene_preset_id?: string;
|
scene_preset_id?: string;
|
||||||
deactivation_mode: 'none' | 'revert' | 'fallback_scene';
|
deactivation_mode: 'none' | 'revert' | 'fallback_scene';
|
||||||
deactivation_scene_preset_id?: string;
|
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.gradient_map.desc": "Maps numeric value through a color gradient",
|
||||||
"value_source.type.css_extract": "Strip Extract",
|
"value_source.type.css_extract": "Strip Extract",
|
||||||
"value_source.type.css_extract.desc": "Extracts color from a color strip source",
|
"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.input": "Input Value Source",
|
||||||
"value_source.gradient_map.gradient": "Gradient",
|
"value_source.gradient_map.gradient": "Gradient",
|
||||||
"value_source.css_extract.source": "Color Strip Source",
|
"value_source.css_extract.source": "Color Strip Source",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - Navigation: network-first with offline fallback
|
* - Navigation: network-first with offline fallback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_NAME = 'ledgrab-v33';
|
const CACHE_NAME = 'ledgrab-v34';
|
||||||
|
|
||||||
// Only pre-cache static assets (no auth required).
|
// Only pre-cache static assets (no auth required).
|
||||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
// 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 --
|
# -- Source type registry --
|
||||||
# Maps source_type string to its subclass for factory dispatch.
|
# Maps source_type string to its subclass for factory dispatch.
|
||||||
_VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
|
_VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
|
||||||
@@ -482,4 +544,5 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
|
|||||||
"ha_entity": HAEntityValueSource,
|
"ha_entity": HAEntityValueSource,
|
||||||
"gradient_map": GradientMapValueSource,
|
"gradient_map": GradientMapValueSource,
|
||||||
"css_extract": CSSExtractValueSource,
|
"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.database import Database
|
||||||
from wled_controller.storage.utils import resolve_ref
|
from wled_controller.storage.utils import resolve_ref
|
||||||
from wled_controller.storage.value_source import (
|
from wled_controller.storage.value_source import (
|
||||||
|
VALID_SYSTEM_METRICS,
|
||||||
AdaptiveValueSource,
|
AdaptiveValueSource,
|
||||||
AdaptiveTimeColorValueSource,
|
AdaptiveTimeColorValueSource,
|
||||||
AnimatedColorValueSource,
|
AnimatedColorValueSource,
|
||||||
@@ -19,6 +20,7 @@ from wled_controller.storage.value_source import (
|
|||||||
HAEntityValueSource,
|
HAEntityValueSource,
|
||||||
StaticColorValueSource,
|
StaticColorValueSource,
|
||||||
StaticValueSource,
|
StaticValueSource,
|
||||||
|
SystemMetricsValueSource,
|
||||||
ValueSource,
|
ValueSource,
|
||||||
)
|
)
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
@@ -76,6 +78,11 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
color_strip_source_id: Optional[str] = None,
|
color_strip_source_id: Optional[str] = None,
|
||||||
led_start: Optional[int] = None,
|
led_start: Optional[int] = None,
|
||||||
led_end: 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:
|
) -> ValueSource:
|
||||||
_VALID = (
|
_VALID = (
|
||||||
"static",
|
"static",
|
||||||
@@ -90,6 +97,7 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
"ha_entity",
|
"ha_entity",
|
||||||
"gradient_map",
|
"gradient_map",
|
||||||
"css_extract",
|
"css_extract",
|
||||||
|
"system_metrics",
|
||||||
)
|
)
|
||||||
if source_type not in _VALID:
|
if source_type not in _VALID:
|
||||||
raise ValueError(f"Invalid source type: {source_type}")
|
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_start=led_start if led_start is not None else 0,
|
||||||
led_end=led_end if led_end is not None else -1,
|
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._items[sid] = source
|
||||||
self._save_item(sid, source)
|
self._save_item(sid, source)
|
||||||
@@ -323,6 +352,11 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
color_strip_source_id: Optional[str] = None,
|
color_strip_source_id: Optional[str] = None,
|
||||||
led_start: Optional[int] = None,
|
led_start: Optional[int] = None,
|
||||||
led_end: 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:
|
) -> ValueSource:
|
||||||
source = self.get(source_id)
|
source = self.get(source_id)
|
||||||
|
|
||||||
@@ -434,6 +468,25 @@ class ValueSourceStore(BaseSqliteStore[ValueSource]):
|
|||||||
source.led_start = led_start
|
source.led_start = led_start
|
||||||
if led_end is not None:
|
if led_end is not None:
|
||||||
source.led_end = led_end
|
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)
|
source.updated_at = datetime.now(timezone.utc)
|
||||||
self._save_item(source_id, source)
|
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="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="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="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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -455,6 +456,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Shared adaptive output range (shown for adaptive and daylight types) -->
|
||||||
<div id="value-source-adaptive-range-section" style="display:none">
|
<div id="value-source-adaptive-range-section" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
Reference in New Issue
Block a user