Add tags to all entity types with chip-based input and autocomplete

- Add `tags: List[str]` field to all 13 entity types (devices, output targets,
  CSS sources, picture sources, audio sources, value sources, sync clocks,
  automations, scene presets, capture/audio/PP/pattern templates)
- Update all stores, schemas, and route handlers for tag CRUD
- Add GET /api/v1/tags endpoint aggregating unique tags across all stores
- Create TagInput component with chip display, autocomplete dropdown,
  keyboard navigation, and API-backed suggestions
- Display tag chips on all entity cards (searchable via existing text filter)
- Add tag input to all 14 editor modals with dirty check support
- Add CSS styles and i18n keys (en/ru/zh) for tag UI
- Also includes code review fixes: thread safety, perf, store dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 22:20:19 +03:00
parent 2712c6682e
commit 30fa107ef7
120 changed files with 2471 additions and 1949 deletions

View File

@@ -99,6 +99,17 @@ class AudioAnalyzer:
self._spectrum_buf_right = np.zeros(NUM_BANDS, dtype=np.float32)
self._sq_buf = np.empty(chunk_size, dtype=np.float32)
# Double-buffered output spectra — avoids allocating new arrays each
# analyze() call. Consumers hold a reference to the "old" buffer while
# the analyzer writes into the alternate one.
self._out_spectrum = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_spectrum_left = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_spectrum_right = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_idx = 0 # toggles 0/1 each analyze() call
# Pre-compute band start/end arrays and widths for vectorized binning
self._band_starts = np.array([s for s, _ in self._bands], dtype=np.intp)
self._band_ends = np.array([e for _, e in self._bands], dtype=np.intp)
@@ -168,10 +179,14 @@ class AudioAnalyzer:
# FFT for mono, left, right
self._fft_bands(samples, self._spectrum_buf, self._smooth_spectrum,
alpha, one_minus_alpha)
self._fft_bands(left_samples, self._spectrum_buf_left, self._smooth_spectrum_left,
alpha, one_minus_alpha)
self._fft_bands(right_samples, self._spectrum_buf_right, self._smooth_spectrum_right,
alpha, one_minus_alpha)
if channels > 1:
self._fft_bands(left_samples, self._spectrum_buf_left, self._smooth_spectrum_left,
alpha, one_minus_alpha)
self._fft_bands(right_samples, self._spectrum_buf_right, self._smooth_spectrum_right,
alpha, one_minus_alpha)
else:
np.copyto(self._smooth_spectrum_left, self._smooth_spectrum)
np.copyto(self._smooth_spectrum_right, self._smooth_spectrum)
# Beat detection — compare current energy to rolling average (mono)
np.multiply(samples, samples, out=self._sq_buf[:n])
@@ -188,17 +203,27 @@ class AudioAnalyzer:
beat = True
beat_intensity = min(1.0, (ratio - 1.0) / 2.0)
# Snapshot spectra into double-buffered output arrays (no allocation)
idx = self._out_idx
self._out_idx = 1 - idx
out_spec = self._out_spectrum[idx]
out_left = self._out_spectrum_left[idx]
out_right = self._out_spectrum_right[idx]
np.copyto(out_spec, self._smooth_spectrum)
np.copyto(out_left, self._smooth_spectrum_left)
np.copyto(out_right, self._smooth_spectrum_right)
return AudioAnalysis(
timestamp=time.perf_counter(),
rms=rms,
peak=peak,
spectrum=self._smooth_spectrum.copy(),
spectrum=out_spec,
beat=beat,
beat_intensity=beat_intensity,
left_rms=left_rms,
left_spectrum=self._smooth_spectrum_left.copy(),
left_spectrum=out_left,
right_rms=right_rms,
right_spectrum=self._smooth_spectrum_right.copy(),
right_spectrum=out_right,
)
def _fft_bands(self, samps, buf, smooth_buf, alpha, one_minus_alpha):

View File

@@ -201,20 +201,25 @@ class AutoBackupEngine:
})
return backups
def delete_backup(self, filename: str) -> None:
# Validate filename to prevent path traversal
if os.sep in filename or "/" in filename or ".." in filename:
def _safe_backup_path(self, filename: str) -> Path:
"""Resolve a backup filename to an absolute path, guarding against path traversal."""
if not filename or os.sep in filename or "/" in filename or ".." in filename:
raise ValueError("Invalid filename")
target = self._backup_dir / filename
target = (self._backup_dir / filename).resolve()
# Ensure resolved path is still inside the backup directory
if not target.is_relative_to(self._backup_dir.resolve()):
raise ValueError("Invalid filename")
return target
def delete_backup(self, filename: str) -> None:
target = self._safe_backup_path(filename)
if not target.exists():
raise FileNotFoundError(f"Backup not found: {filename}")
target.unlink()
logger.info(f"Deleted backup: {filename}")
def get_backup_path(self, filename: str) -> Path:
if os.sep in filename or "/" in filename or ".." in filename:
raise ValueError("Invalid filename")
target = self._backup_dir / filename
target = self._safe_backup_path(filename)
if not target.exists():
raise FileNotFoundError(f"Backup not found: {filename}")
return target

View File

@@ -1,7 +1,7 @@
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
import asyncio
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Optional, Tuple
import numpy as np
@@ -199,7 +199,7 @@ class AdalightClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
device_name=prev_health.device_name if prev_health else None,
device_version=None,
device_led_count=prev_health.device_led_count if prev_health else None,
@@ -207,12 +207,12 @@ class AdalightClient(LEDClient):
else:
return DeviceHealth(
online=False,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
error=f"Serial port {port} not found",
)
except Exception as e:
return DeviceHealth(
online=False,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
error=str(e),
)

View File

@@ -190,9 +190,12 @@ class DDPClient:
try:
# Send plain RGB — WLED handles per-bus color order conversion
# internally when outputting to hardware.
# Convert to numpy to avoid per-pixel Python loop
# Accept numpy arrays directly to avoid per-pixel Python loop
bpp = 4 if self.rgbw else 3 # bytes per pixel
pixel_array = np.array(pixels, dtype=np.uint8)
if isinstance(pixels, np.ndarray):
pixel_array = pixels
else:
pixel_array = np.array(pixels, dtype=np.uint8)
if self.rgbw:
n = pixel_array.shape[0]
if n != self._rgbw_buf_n:
@@ -219,7 +222,7 @@ class DDPClient:
for i in range(num_packets):
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = bytes(pixel_bytes[start:end])
chunk = pixel_bytes[start:end]
is_last = (i == num_packets - 1)
# Increment sequence number

View File

@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
@@ -139,7 +139,7 @@ class LEDClient(ABC):
http_client: Shared httpx.AsyncClient for HTTP requests
prev_health: Previous health result (for preserving cached metadata)
"""
return DeviceHealth(online=True, last_checked=datetime.utcnow())
return DeviceHealth(online=True, last_checked=datetime.now(timezone.utc))
async def __aenter__(self):
await self.connect()

View File

@@ -1,7 +1,7 @@
"""Mock LED client — simulates an LED strip with configurable latency for testing."""
import asyncio
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
@@ -69,5 +69,5 @@ class MockClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
)

View File

@@ -1,6 +1,6 @@
"""Mock device provider — virtual LED strip for testing."""
from datetime import datetime
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
@@ -28,7 +28,7 @@ class MockDeviceProvider(LEDDeviceProvider):
return MockClient(url, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.utcnow())
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
async def validate_device(self, url: str) -> dict:
return {}

View File

@@ -87,12 +87,12 @@ class MQTTLEDClient(LEDClient):
http_client,
prev_health=None,
) -> DeviceHealth:
from datetime import datetime
from datetime import datetime, timezone
svc = _mqtt_service
if svc is None or not svc.is_enabled:
return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.utcnow())
return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.now(timezone.utc))
return DeviceHealth(
online=svc.is_connected,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
error=None if svc.is_connected else "MQTT broker disconnected",
)

View File

@@ -4,7 +4,7 @@ import asyncio
import socket
import struct
import threading
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
@@ -428,13 +428,13 @@ class OpenRGBLEDClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=latency,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
device_name=device_name,
device_led_count=device_led_count,
)
except Exception as e:
return DeviceHealth(
online=False,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
error=str(e),
)

View File

@@ -3,7 +3,7 @@
import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse
@@ -540,7 +540,7 @@ class WLEDClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=round(latency, 1),
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
device_name=data.get("name"),
device_version=data.get("ver"),
device_led_count=leds_info.get("count"),
@@ -553,7 +553,7 @@ class WLEDClient(LEDClient):
return DeviceHealth(
online=False,
latency_ms=None,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
device_name=prev_health.device_name if prev_health else None,
device_version=prev_health.device_version if prev_health else None,
device_led_count=prev_health.device_led_count if prev_health else None,

View File

@@ -1,7 +1,7 @@
"""WebSocket LED client — broadcasts pixel data to connected WebSocket clients."""
import asyncio
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
@@ -126,5 +126,5 @@ class WSLEDClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
)

View File

@@ -1,6 +1,6 @@
"""WebSocket device provider — factory, validation, health checks."""
from datetime import datetime
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
@@ -33,7 +33,7 @@ class WSDeviceProvider(LEDDeviceProvider):
self, url: str, http_client, prev_health=None,
) -> DeviceHealth:
return DeviceHealth(
online=True, latency_ms=0.0, last_checked=datetime.utcnow(),
online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc),
)
async def validate_device(self, url: str) -> dict:

View File

@@ -46,6 +46,7 @@ class AudioColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
# Per-frame timing (read by WledTargetProcessor via get_last_timing())
self._last_timing: dict = {}
@@ -128,6 +129,7 @@ class AudioColorStripStream(ColorStripStream):
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
def start(self) -> None:
if self._running:
@@ -233,7 +235,7 @@ class AudioColorStripStream(ColorStripStream):
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
frame_time = 1.0 / self._fps
frame_time = self._frame_time
try:
n = self._led_count

View File

@@ -587,6 +587,7 @@ class StaticColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None # optional SyncClockRuntime
self._update_from_source(source)
@@ -636,6 +637,7 @@ class StaticColorStripStream(ColorStripStream):
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
self._frame_time = 1.0 / fps
def start(self) -> None:
if self._running:
@@ -693,7 +695,7 @@ class StaticColorStripStream(ColorStripStream):
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
frame_time = self._frame_time
try:
anim = self._animation
if anim and anim.get("enabled"):
@@ -807,6 +809,7 @@ class ColorCycleColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None # optional SyncClockRuntime
self._update_from_source(source)
@@ -849,6 +852,7 @@ class ColorCycleColorStripStream(ColorStripStream):
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
self._frame_time = 1.0 / fps
def start(self) -> None:
if self._running:
@@ -902,7 +906,7 @@ class ColorCycleColorStripStream(ColorStripStream):
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
frame_time = self._frame_time
try:
color_list = self._color_list
clock = self._clock
@@ -967,6 +971,7 @@ class GradientColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None # optional SyncClockRuntime
self._update_from_source(source)
@@ -1015,6 +1020,7 @@ class GradientColorStripStream(ColorStripStream):
"""Update animation loop rate. Thread-safe (read atomically by the loop)."""
fps = max(1, min(90, fps))
self._fps = fps
self._frame_time = 1.0 / fps
def start(self) -> None:
if self._running:
@@ -1077,7 +1083,7 @@ class GradientColorStripStream(ColorStripStream):
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
frame_time = self._frame_time
try:
anim = self._animation
if anim and anim.get("enabled"):

View File

@@ -46,6 +46,8 @@ class _ColorStripEntry:
picture_source_ids: list = None
# Per-consumer target FPS values (target_id → fps)
target_fps: Dict[str, int] = None
# Clock ID currently acquired for this stream (for correct release)
clock_id: Optional[str] = None
def __post_init__(self):
if self.picture_source_ids is None:
@@ -79,24 +81,36 @@ class ColorStripStreamManager:
self._sync_clock_manager = sync_clock_manager
self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> None:
"""Inject a SyncClockRuntime into the stream if source has clock_id."""
def _inject_clock(self, css_stream, source) -> Optional[str]:
"""Inject a SyncClockRuntime into the stream if source has clock_id.
Returns the clock_id that was acquired, or None.
"""
clock_id = getattr(source, "clock_id", None)
if clock_id and self._sync_clock_manager and hasattr(css_stream, "set_clock"):
try:
clock_rt = self._sync_clock_manager.acquire(clock_id)
css_stream.set_clock(clock_rt)
logger.debug(f"Injected clock {clock_id} into stream for {source.id}")
return clock_id
except Exception as e:
logger.warning(f"Could not inject clock {clock_id}: {e}")
return None
def _release_clock(self, source_id: str, stream) -> None:
"""Release the clock runtime acquired for a stream."""
def _release_clock(self, source_id: str, stream, clock_id: str = None) -> None:
"""Release the clock runtime acquired for a stream.
Args:
source_id: CSS source ID (used as fallback to look up clock_id from store)
stream: The stream instance (unused, kept for API compat)
clock_id: Explicit clock_id to release. If None, looks up from store.
"""
if not self._sync_clock_manager:
return
try:
source = self._color_strip_store.get_source(source_id)
clock_id = getattr(source, "clock_id", None)
if not clock_id:
source = self._color_strip_store.get_source(source_id)
clock_id = getattr(source, "clock_id", None)
if clock_id:
self._sync_clock_manager.release(clock_id)
except Exception:
@@ -153,11 +167,12 @@ class ColorStripStreamManager:
)
css_stream = stream_cls(source)
# Inject sync clock runtime if source references a clock
self._inject_clock(css_stream, source)
acquired_clock_id = self._inject_clock(css_stream, source)
css_stream.start()
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
self._streams[key] = _ColorStripEntry(
stream=css_stream, ref_count=1, picture_source_ids=[],
clock_id=acquired_clock_id,
)
logger.info(f"Created {source.source_type} stream {key}")
return css_stream
@@ -249,8 +264,9 @@ class ColorStripStreamManager:
logger.error(f"Error stopping color strip stream {key}: {e}")
# Release clock runtime if acquired
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream)
if entry.clock_id:
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream, clock_id=entry.clock_id)
picture_source_ids = entry.picture_source_ids
del self._streams[key]
@@ -282,26 +298,28 @@ class ColorStripStreamManager:
for key in matching_keys:
entry = self._streams[key]
old_clock_id = entry.clock_id
entry.stream.update_source(new_source)
# Hot-swap clock if clock_id changed
if hasattr(entry.stream, "set_clock") and self._sync_clock_manager:
new_clock_id = getattr(new_source, "clock_id", None)
old_clock = getattr(entry.stream, "_clock", None)
if new_clock_id:
try:
clock_rt = self._sync_clock_manager.acquire(new_clock_id)
entry.stream.set_clock(clock_rt)
# Release old clock if different
if old_clock:
# Find the old clock_id (best-effort)
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream)
except Exception as e:
logger.warning(f"Could not hot-swap clock {new_clock_id}: {e}")
elif old_clock:
if new_clock_id != old_clock_id:
try:
clock_rt = self._sync_clock_manager.acquire(new_clock_id)
entry.stream.set_clock(clock_rt)
entry.clock_id = new_clock_id
# Release old clock after acquiring new one
if old_clock_id:
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream, clock_id=old_clock_id)
except Exception as e:
logger.warning(f"Could not hot-swap clock {new_clock_id}: {e}")
elif old_clock_id:
entry.stream.set_clock(None)
entry.clock_id = None
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream)
self._release_clock(source_id, entry.stream, clock_id=old_clock_id)
# Track picture source changes for future reference counting
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource

View File

@@ -36,6 +36,7 @@ class CompositeColorStripStream(ColorStripStream):
self._auto_size: bool = source.led_count == 0
self._css_manager = css_manager
self._fps: int = 30
self._frame_time: float = 1.0 / 30
self._running = False
self._thread: Optional[threading.Thread] = None
@@ -44,6 +45,7 @@ class CompositeColorStripStream(ColorStripStream):
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
# Pre-allocated scratch (rebuilt when LED count changes)
self._pool_n = 0
@@ -60,6 +62,10 @@ class CompositeColorStripStream(ColorStripStream):
def target_fps(self) -> int:
return self._fps
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
@property
def led_count(self) -> int:
return self._led_count
@@ -69,7 +75,8 @@ class CompositeColorStripStream(ColorStripStream):
return True
def start(self) -> None:
self._acquire_sub_streams()
with self._sub_lock:
self._acquire_sub_streams()
self._running = True
self._thread = threading.Thread(
target=self._processing_loop, daemon=True,
@@ -86,7 +93,8 @@ class CompositeColorStripStream(ColorStripStream):
if self._thread is not None:
self._thread.join(timeout=5.0)
self._thread = None
self._release_sub_streams()
with self._sub_lock:
self._release_sub_streams()
logger.info(f"CompositeColorStripStream stopped: {self._source_id}")
def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -97,7 +105,9 @@ class CompositeColorStripStream(ColorStripStream):
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
# Re-configure sub-streams that support auto-sizing
for _idx, (src_id, consumer_id, stream) in self._sub_streams.items():
with self._sub_lock:
snapshot = dict(self._sub_streams)
for _idx, (src_id, consumer_id, stream) in snapshot.items():
if hasattr(stream, "configure"):
stream.configure(device_led_count)
logger.debug(f"CompositeColorStripStream auto-sized to {device_led_count} LEDs")
@@ -118,8 +128,9 @@ class CompositeColorStripStream(ColorStripStream):
# If layer composition changed, rebuild sub-streams
if old_layer_ids != new_layer_ids:
self._release_sub_streams()
self._acquire_sub_streams()
with self._sub_lock:
self._release_sub_streams()
self._acquire_sub_streams()
logger.info(f"CompositeColorStripStream rebuilt sub-streams: {self._source_id}")
# ── Sub-stream lifecycle ────────────────────────────────────
@@ -256,7 +267,7 @@ class CompositeColorStripStream(ColorStripStream):
try:
while self._running:
loop_start = time.perf_counter()
frame_time = 1.0 / self._fps
frame_time = self._frame_time
try:
target_n = self._led_count
@@ -270,13 +281,16 @@ class CompositeColorStripStream(ColorStripStream):
self._use_a = not self._use_a
has_result = False
with self._sub_lock:
sub_snapshot = dict(self._sub_streams)
for i, layer in enumerate(self._layers):
if not layer.get("enabled", True):
continue
if i not in self._sub_streams:
if i not in sub_snapshot:
continue
_src_id, _consumer_id, stream = self._sub_streams[i]
_src_id, _consumer_id, stream = sub_snapshot[i]
colors = stream.get_latest_colors()
if colors is None:
continue

View File

@@ -182,6 +182,7 @@ class EffectColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
self._clock = None # optional SyncClockRuntime
self._effective_speed = 1.0 # resolved speed (from clock or source)
self._noise = _ValueNoise1D(seed=42)
@@ -233,6 +234,7 @@ class EffectColorStripStream(ColorStripStream):
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
def start(self) -> None:
if self._running:
@@ -294,7 +296,7 @@ class EffectColorStripStream(ColorStripStream):
with high_resolution_timer():
while self._running:
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
frame_time = self._frame_time
try:
# Resolve animation time and speed from clock or local
clock = self._clock

View File

@@ -6,7 +6,7 @@ import asyncio
import collections
import json
import time
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
import cv2
@@ -169,7 +169,7 @@ class KCTargetProcessor(TargetProcessor):
self._value_stream = None
# Reset metrics
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
self._metrics = ProcessingMetrics(start_time=datetime.now(timezone.utc))
self._previous_colors = None
self._latest_colors = None
@@ -276,7 +276,7 @@ class KCTargetProcessor(TargetProcessor):
metrics = self._metrics
uptime = 0.0
if metrics.start_time and self._is_running:
uptime = (datetime.utcnow() - metrics.start_time).total_seconds()
uptime = (datetime.now(timezone.utc) - metrics.start_time).total_seconds()
return {
"target_id": self._target_id,
@@ -417,7 +417,7 @@ class KCTargetProcessor(TargetProcessor):
# Update metrics
self._metrics.frames_processed += 1
self._metrics.last_update = datetime.utcnow()
self._metrics.last_update = datetime.now(timezone.utc)
# Calculate actual FPS
now = time.perf_counter()
@@ -452,6 +452,7 @@ class KCTargetProcessor(TargetProcessor):
except Exception as e:
logger.error(f"Fatal error in KC processing loop for target {self._target_id}: {e}")
self._is_running = False
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False, "crashed": True})
raise
finally:
logger.info(f"KC processing loop ended for target {self._target_id}")
@@ -468,7 +469,7 @@ class KCTargetProcessor(TargetProcessor):
name: {"r": c[0], "g": c[1], "b": c[2]}
for name, c in colors.items()
},
"timestamp": datetime.utcnow().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
})
async def _send_safe(ws):
@@ -478,8 +479,9 @@ class KCTargetProcessor(TargetProcessor):
except Exception:
return False
results = await asyncio.gather(*[_send_safe(ws) for ws in self._ws_clients])
clients = list(self._ws_clients)
results = await asyncio.gather(*[_send_safe(ws) for ws in clients])
disconnected = [ws for ws, ok in zip(self._ws_clients, results) if not ok]
for ws in disconnected:
self._ws_clients.remove(ws)
for ws, ok in zip(clients, results):
if not ok and ws in self._ws_clients:
self._ws_clients.remove(ws)

View File

@@ -75,6 +75,7 @@ class ScreenCaptureLiveStream(LiveStream):
def __init__(self, capture_stream: CaptureStream, fps: int):
self._capture_stream = capture_stream
self._fps = fps
self._frame_time = 1.0 / fps if fps > 0 else 1.0
self._latest_frame: Optional[ScreenCapture] = None
self._frame_lock = threading.Lock()
self._running = False
@@ -128,7 +129,7 @@ class ScreenCaptureLiveStream(LiveStream):
return self._latest_frame
def _capture_loop(self) -> None:
frame_time = 1.0 / self._fps if self._fps > 0 else 1.0
frame_time = self._frame_time
consecutive_errors = 0
try:
with high_resolution_timer():

View File

@@ -31,6 +31,7 @@ class MappedColorStripStream(ColorStripStream):
self._auto_size: bool = source.led_count == 0
self._css_manager = css_manager
self._fps: int = 30
self._frame_time: float = 1.0 / 30
self._running = False
self._thread: Optional[threading.Thread] = None
@@ -39,6 +40,7 @@ class MappedColorStripStream(ColorStripStream):
# zone_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
# ── ColorStripStream interface ──────────────────────────────
@@ -46,6 +48,10 @@ class MappedColorStripStream(ColorStripStream):
def target_fps(self) -> int:
return self._fps
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
@property
def led_count(self) -> int:
return self._led_count
@@ -55,7 +61,8 @@ class MappedColorStripStream(ColorStripStream):
return True
def start(self) -> None:
self._acquire_sub_streams()
with self._sub_lock:
self._acquire_sub_streams()
self._running = True
self._thread = threading.Thread(
target=self._processing_loop, daemon=True,
@@ -72,7 +79,8 @@ class MappedColorStripStream(ColorStripStream):
if self._thread is not None:
self._thread.join(timeout=5.0)
self._thread = None
self._release_sub_streams()
with self._sub_lock:
self._release_sub_streams()
logger.info(f"MappedColorStripStream stopped: {self._source_id}")
def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -82,7 +90,8 @@ class MappedColorStripStream(ColorStripStream):
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
self._reconfigure_sub_streams()
with self._sub_lock:
self._reconfigure_sub_streams()
logger.debug(f"MappedColorStripStream auto-sized to {device_led_count} LEDs")
def update_source(self, source) -> None:
@@ -100,8 +109,9 @@ class MappedColorStripStream(ColorStripStream):
self._auto_size = False
if old_zone_ids != new_zone_ids:
self._release_sub_streams()
self._acquire_sub_streams()
with self._sub_lock:
self._release_sub_streams()
self._acquire_sub_streams()
logger.info(f"MappedColorStripStream rebuilt sub-streams: {self._source_id}")
# ── Sub-stream lifecycle ────────────────────────────────────
@@ -152,7 +162,7 @@ class MappedColorStripStream(ColorStripStream):
# ── Processing loop ─────────────────────────────────────────
def _processing_loop(self) -> None:
frame_time = 1.0 / self._fps
frame_time = self._frame_time
try:
while self._running:
loop_start = time.perf_counter()
@@ -165,11 +175,14 @@ class MappedColorStripStream(ColorStripStream):
result = np.zeros((target_n, 3), dtype=np.uint8)
with self._sub_lock:
sub_snapshot = dict(self._sub_streams)
for i, zone in enumerate(self._zones):
if i not in self._sub_streams:
if i not in sub_snapshot:
continue
_src_id, _consumer_id, stream = self._sub_streams[i]
_src_id, _consumer_id, stream = sub_snapshot[i]
colors = stream.get_latest_colors()
if colors is None:
continue

View File

@@ -2,7 +2,7 @@
import asyncio
from collections import deque
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, Optional
from wled_controller.utils import get_logger
@@ -22,7 +22,7 @@ def _collect_system_snapshot() -> dict:
mem = psutil.virtual_memory()
snapshot = {
"t": datetime.utcnow().isoformat(),
"t": datetime.now(timezone.utc).isoformat(),
"cpu": psutil.cpu_percent(interval=None),
"ram_pct": mem.percent,
"ram_used": round(mem.used / 1024 / 1024, 1),
@@ -95,7 +95,7 @@ class MetricsHistory:
except Exception:
all_states = {}
now = datetime.utcnow().isoformat()
now = datetime.now(timezone.utc).isoformat()
active_ids = set()
for target_id, state in all_states.items():
active_ids.add(target_id)

View File

@@ -53,6 +53,7 @@ class NotificationColorStripStream(ColorStripStream):
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
# Event queue: deque of (color_rgb_tuple, start_time)
self._event_queue: collections.deque = collections.deque(maxlen=16)
@@ -119,6 +120,10 @@ class NotificationColorStripStream(ColorStripStream):
def target_fps(self) -> int:
return self._fps
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
@property
def is_animated(self) -> bool:
return True
@@ -179,7 +184,7 @@ class NotificationColorStripStream(ColorStripStream):
try:
while self._running:
wall_start = time.perf_counter()
frame_time = 1.0 / self._fps
frame_time = self._frame_time
try:
# Check for new events

View File

@@ -122,6 +122,10 @@ class ProcessorManager:
def metrics_history(self) -> MetricsHistory:
return self._metrics_history
@property
def color_strip_stream_manager(self) -> ColorStripStreamManager:
return self._color_strip_stream_manager
# ===== SHARED CONTEXT (passed to target processors) =====
def _build_context(self) -> TargetContext:
@@ -821,8 +825,8 @@ class ProcessorManager:
return
# Skip periodic health checks for virtual devices (always online)
if "health_check" not in get_device_capabilities(state.device_type):
from datetime import datetime
state.health = DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.utcnow())
from datetime import datetime, timezone
state.health = DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
return
if state.health_task and not state.health_task.done():
return
@@ -897,6 +901,22 @@ class ProcessorManager:
# ===== HELPERS =====
def has_device(self, device_id: str) -> bool:
"""Check if a device is registered."""
return device_id in self._devices
def find_device_state(self, device_id: str) -> Optional[DeviceState]:
"""Get device state, returning None if not registered."""
return self._devices.get(device_id)
async def send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to a device (public wrapper)."""
await self._send_clear_pixels(device_id)
def get_processor(self, target_id: str) -> Optional[TargetProcessor]:
"""Look up a processor by target_id, returning None if not found."""
return self._processors.get(target_id)
def _get_processor(self, target_id: str) -> TargetProcessor:
"""Look up a processor by target_id, raising ValueError if not found."""
proc = self._processors.get(target_id)

View File

@@ -4,6 +4,7 @@ Runtimes are created lazily when a stream first acquires a clock and
destroyed when the last consumer releases it.
"""
import threading
from typing import Dict, Optional
from wled_controller.core.processing.sync_clock_runtime import SyncClockRuntime
@@ -18,6 +19,7 @@ class SyncClockManager:
def __init__(self, store: SyncClockStore) -> None:
self._store = store
self._lock = threading.Lock()
self._runtimes: Dict[str, SyncClockRuntime] = {}
self._ref_counts: Dict[str, int] = {}
@@ -25,56 +27,62 @@ class SyncClockManager:
def acquire(self, clock_id: str) -> SyncClockRuntime:
"""Get or create a runtime for *clock_id* (ref-counted)."""
if clock_id in self._runtimes:
self._ref_counts[clock_id] += 1
logger.debug(f"SyncClock {clock_id} ref++ → {self._ref_counts[clock_id]}")
return self._runtimes[clock_id]
with self._lock:
if clock_id in self._runtimes:
self._ref_counts[clock_id] += 1
logger.debug(f"SyncClock {clock_id} ref++ → {self._ref_counts[clock_id]}")
return self._runtimes[clock_id]
clock_cfg = self._store.get_clock(clock_id) # raises ValueError if missing
rt = SyncClockRuntime(speed=clock_cfg.speed)
self._runtimes[clock_id] = rt
self._ref_counts[clock_id] = 1
logger.info(f"SyncClock runtime created: {clock_id} (speed={clock_cfg.speed})")
return rt
clock_cfg = self._store.get_clock(clock_id) # raises ValueError if missing
rt = SyncClockRuntime(speed=clock_cfg.speed)
self._runtimes[clock_id] = rt
self._ref_counts[clock_id] = 1
logger.info(f"SyncClock runtime created: {clock_id} (speed={clock_cfg.speed})")
return rt
def release(self, clock_id: str) -> None:
"""Decrement ref count; destroy runtime when it reaches zero."""
if clock_id not in self._ref_counts:
return
self._ref_counts[clock_id] -= 1
logger.debug(f"SyncClock {clock_id} ref-- → {self._ref_counts[clock_id]}")
if self._ref_counts[clock_id] <= 0:
del self._runtimes[clock_id]
del self._ref_counts[clock_id]
logger.info(f"SyncClock runtime destroyed: {clock_id}")
with self._lock:
if clock_id not in self._ref_counts:
return
self._ref_counts[clock_id] -= 1
logger.debug(f"SyncClock {clock_id} ref-- → {self._ref_counts[clock_id]}")
if self._ref_counts[clock_id] <= 0:
del self._runtimes[clock_id]
del self._ref_counts[clock_id]
logger.info(f"SyncClock runtime destroyed: {clock_id}")
def release_all_for(self, clock_id: str) -> None:
"""Force-release all references to *clock_id* (used on delete)."""
self._runtimes.pop(clock_id, None)
self._ref_counts.pop(clock_id, None)
with self._lock:
self._runtimes.pop(clock_id, None)
self._ref_counts.pop(clock_id, None)
def release_all(self) -> None:
"""Destroy all runtimes (shutdown)."""
self._runtimes.clear()
self._ref_counts.clear()
with self._lock:
self._runtimes.clear()
self._ref_counts.clear()
# ── Lookup (no ref counting) ──────────────────────────────────
def get_runtime(self, clock_id: str) -> Optional[SyncClockRuntime]:
"""Return an existing runtime or *None* (does not create one)."""
return self._runtimes.get(clock_id)
with self._lock:
return self._runtimes.get(clock_id)
def _ensure_runtime(self, clock_id: str) -> SyncClockRuntime:
"""Return existing runtime or create a zero-ref one for API control."""
rt = self._runtimes.get(clock_id)
if rt:
with self._lock:
rt = self._runtimes.get(clock_id)
if rt:
return rt
clock_cfg = self._store.get_clock(clock_id)
rt = SyncClockRuntime(speed=clock_cfg.speed)
self._runtimes[clock_id] = rt
self._ref_counts[clock_id] = 0
logger.info(f"SyncClock runtime created (API): {clock_id} (speed={clock_cfg.speed})")
return rt
clock_cfg = self._store.get_clock(clock_id)
rt = SyncClockRuntime(speed=clock_cfg.speed)
self._runtimes[clock_id] = rt
self._ref_counts[clock_id] = 0
logger.info(f"SyncClock runtime created (API): {clock_id} (speed={clock_cfg.speed})")
return rt
# ── Delegated control ─────────────────────────────────────────

View File

@@ -44,9 +44,10 @@ class SyncClockRuntime:
Returns *real* (wall-clock) elapsed time, not speed-scaled.
"""
if not self._running:
return self._offset
return self._offset + (time.perf_counter() - self._epoch)
with self._lock:
if not self._running:
return self._offset
return self._offset + (time.perf_counter() - self._epoch)
# ── Control ────────────────────────────────────────────────────

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import collections
import time
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional
import httpx
@@ -173,7 +173,7 @@ class WledTargetProcessor(TargetProcessor):
self._value_stream = None
# Reset metrics and start loop
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
self._metrics = ProcessingMetrics(start_time=datetime.now(timezone.utc))
self._is_running = True
self._task = asyncio.create_task(self._processing_loop())
@@ -404,7 +404,7 @@ class WledTargetProcessor(TargetProcessor):
fps_target = self._target_fps
uptime_seconds = 0.0
if metrics.start_time and self._is_running:
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
uptime_seconds = (datetime.now(timezone.utc) - metrics.start_time).total_seconds()
return {
"target_id": self._target_id,
@@ -514,11 +514,12 @@ class WledTargetProcessor(TargetProcessor):
except Exception:
return False
results = await asyncio.gather(*[_send_safe(ws) for ws in self._preview_clients])
clients = list(self._preview_clients)
results = await asyncio.gather(*[_send_safe(ws) for ws in clients])
disconnected = [ws for ws, ok in zip(self._preview_clients, results) if not ok]
for ws in disconnected:
self._preview_clients.remove(ws)
for ws, ok in zip(clients, results):
if not ok and ws in self._preview_clients:
self._preview_clients.remove(ws)
# ----- Private: processing loop -----
@@ -808,7 +809,7 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.timing_send_ms = send_ms
self._metrics.frames_processed += 1
self._metrics.last_update = datetime.utcnow()
self._metrics.last_update = datetime.now(timezone.utc)
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
logger.info(
@@ -898,6 +899,7 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.last_error = f"FATAL: {e}"
self._metrics.errors_count += 1
self._is_running = False
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False, "crashed": True})
raise
finally:
# Clean up probe client

View File

@@ -30,7 +30,7 @@ def capture_current_snapshot(
for t in target_store.get_all_targets():
if target_ids is not None and t.id not in target_ids:
continue
proc = processor_manager._processors.get(t.id)
proc = processor_manager.get_processor(t.id)
running = proc.is_running if proc else False
targets.append(TargetSnapshot(
target_id=t.id,
@@ -65,7 +65,7 @@ async def apply_scene_state(
for ts in preset.targets:
if not ts.running:
try:
proc = processor_manager._processors.get(ts.target_id)
proc = processor_manager.get_processor(ts.target_id)
if proc and proc.is_running:
await processor_manager.stop_processing(ts.target_id)
except Exception as e:
@@ -87,7 +87,7 @@ async def apply_scene_state(
target_store.update_target(ts.target_id, **changed)
# Sync live processor if running
proc = processor_manager._processors.get(ts.target_id)
proc = processor_manager.get_processor(ts.target_id)
if proc and proc.is_running:
css_changed = "color_strip_source_id" in changed
bvs_changed = "brightness_value_source_id" in changed
@@ -107,7 +107,7 @@ async def apply_scene_state(
for ts in preset.targets:
if ts.running:
try:
proc = processor_manager._processors.get(ts.target_id)
proc = processor_manager.get_processor(ts.target_id)
if not proc or not proc.is_running:
await processor_manager.start_processing(ts.target_id)
except Exception as e: