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