Rework API input CSS: segments, remove led_count, HAOS light, test preview

API Input CSS rework:
- Remove led_count field from ApiInputColorStripSource (always auto-sizes)
- Add segment-based payload: solid, per_pixel, gradient modes
- Segments applied in order (last wins on overlap), auto-grow buffer
- Backward compatible: legacy {"colors": [...]} still works
- Pydantic validation: mode-specific field requirements

Test preview:
- Enable test preview button on api_input cards
- Hide LED/FPS controls for api_input (sender controls those)
- Show input source selector for all CSS tests (preselected)
- FPS sparkline chart using shared createFpsSparkline (same as target cards)
- Server only sends frames when push_generation changes (no idle frames)

HAOS integration:
- New light.py: ApiInputLight entity per api_input source (RGB + brightness)
- turn_on pushes solid segment, turn_off pushes fallback color
- Register wled_screen_controller.set_leds service for arbitrary segments
- New services.yaml with field definitions
- Coordinator: push_colors() and push_segments() methods
- Platform.LIGHT added to platforms list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 14:47:42 +03:00
parent 823cb90d2d
commit 8a6ffca446
25 changed files with 1085 additions and 326 deletions

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging import logging
from datetime import timedelta from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.BUTTON, Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH, Platform.SWITCH,
Platform.SENSOR, Platform.SENSOR,
Platform.NUMBER, Platform.NUMBER,
@@ -148,6 +151,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator.async_add_listener(_on_coordinator_update) coordinator.async_add_listener(_on_coordinator_update)
# Register set_leds service (once across all entries)
async def handle_set_leds(call) -> None:
"""Handle the set_leds service call."""
source_id = call.data["source_id"]
segments = call.data["segments"]
for entry_data in hass.data[DOMAIN].values():
coord = entry_data.get(DATA_COORDINATOR)
if coord:
await coord.push_segments(source_id, segments)
break
if not hass.services.has_service(DOMAIN, "set_leds"):
hass.services.async_register(
DOMAIN,
"set_leds",
handle_set_leds,
schema=vol.Schema({
vol.Required("source_id"): str,
vol.Required("segments"): list,
}),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True

View File

@@ -336,6 +336,38 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch scene presets: %s", err) _LOGGER.warning("Failed to fetch scene presets: %s", err)
return [] return []
async def push_colors(self, source_id: str, colors: list[list[int]]) -> None:
"""Push flat color array to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"colors": colors},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push colors to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def push_segments(self, source_id: str, segments: list[dict]) -> None:
"""Push segment data to an api_input CSS source."""
async with self.session.post(
f"{self.server_url}/api/v1/color-strip-sources/{source_id}/colors",
headers={**self._auth_headers, "Content-Type": "application/json"},
json={"segments": segments},
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT),
) as resp:
if resp.status not in (200, 204):
body = await resp.text()
_LOGGER.error(
"Failed to push segments to source %s: %s %s",
source_id, resp.status, body,
)
resp.raise_for_status()
async def activate_scene(self, preset_id: str) -> None: async def activate_scene(self, preset_id: str) -> None:
"""Activate a scene preset.""" """Activate a scene preset."""
async with self.session.post( async with self.session.post(

View File

@@ -0,0 +1,140 @@
"""Light platform for LED Screen Controller (api_input CSS sources)."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, DATA_COORDINATOR
from .coordinator import WLEDScreenControllerCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LED Screen Controller api_input lights."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: WLEDScreenControllerCoordinator = data[DATA_COORDINATOR]
entities = []
if coordinator.data:
for source in coordinator.data.get("css_sources", []):
if source.get("source_type") == "api_input":
entities.append(
ApiInputLight(coordinator, source, entry.entry_id)
)
async_add_entities(entities)
class ApiInputLight(CoordinatorEntity, LightEntity):
"""Representation of an api_input CSS source as a light entity."""
_attr_has_entity_name = True
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
_attr_translation_key = "api_input_light"
_attr_icon = "mdi:led-strip-variant"
def __init__(
self,
coordinator: WLEDScreenControllerCoordinator,
source: dict[str, Any],
entry_id: str,
) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._source_id: str = source["id"]
self._source_name: str = source.get("name", self._source_id)
self._entry_id = entry_id
self._attr_unique_id = f"{self._source_id}_light"
# Local state — not derived from coordinator data
self._is_on: bool = False
self._rgb_color: tuple[int, int, int] = (255, 255, 255)
self._brightness: int = 255
@property
def device_info(self) -> dict[str, Any]:
"""Return device information — one virtual device per api_input source."""
return {
"identifiers": {(DOMAIN, self._source_id)},
"name": self._source_name,
"manufacturer": "WLED Screen Controller",
"model": "API Input CSS Source",
}
@property
def name(self) -> str:
"""Return the entity name."""
return self._source_name
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self._is_on
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Return the current RGB color."""
return self._rgb_color
@property
def brightness(self) -> int:
"""Return the current brightness (0-255)."""
return self._brightness
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light, optionally setting color and brightness."""
if ATTR_RGB_COLOR in kwargs:
self._rgb_color = kwargs[ATTR_RGB_COLOR]
if ATTR_BRIGHTNESS in kwargs:
self._brightness = kwargs[ATTR_BRIGHTNESS]
# Scale RGB by brightness
scale = self._brightness / 255
r, g, b = self._rgb_color
scaled = [round(r * scale), round(g * scale), round(b * scale)]
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": scaled}],
)
self._is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light by pushing the fallback color."""
fallback_color = self._get_fallback_color()
await self.coordinator.push_segments(
self._source_id,
[{"start": 0, "length": 9999, "mode": "solid", "color": fallback_color}],
)
self._is_on = False
self.async_write_ha_state()
def _get_fallback_color(self) -> list[int]:
"""Read fallback_color from the source config in coordinator data."""
if not self.coordinator.data:
return [0, 0, 0]
for source in self.coordinator.data.get("css_sources", []):
if source.get("id") == self._source_id:
fallback = source.get("fallback_color")
if fallback and len(fallback) >= 3:
return list(fallback[:3])
break
return [0, 0, 0]

View File

@@ -0,0 +1,19 @@
set_leds:
name: Set LEDs
description: Push segment data to an api_input color strip source
fields:
source_id:
name: Source ID
description: The api_input CSS source ID (e.g., css_abc12345)
required: true
selector:
text:
segments:
name: Segments
description: >
List of segment objects. Each segment has: start (int), length (int),
mode ("solid"/"per_pixel"/"gradient"), color ([R,G,B] for solid),
colors ([[R,G,B],...] for per_pixel/gradient)
required: true
selector:
object:

View File

@@ -31,6 +31,11 @@
"name": "{scene_name}" "name": "{scene_name}"
} }
}, },
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": { "switch": {
"processing": { "processing": {
"name": "Processing" "name": "Processing"
@@ -66,5 +71,21 @@
"name": "Brightness Source" "name": "Brightness Source"
} }
} }
},
"services": {
"set_leds": {
"name": "Set LEDs",
"description": "Push segment data to an api_input color strip source.",
"fields": {
"source_id": {
"name": "Source ID",
"description": "The api_input CSS source ID (e.g., css_abc12345)."
},
"segments": {
"name": "Segments",
"description": "List of segment objects with start, length, mode, and color/colors fields."
}
}
}
} }
} }

View File

@@ -410,7 +410,8 @@ async def push_colors(
): ):
"""Push raw LED colors to an api_input color strip source. """Push raw LED colors to an api_input color strip source.
The colors are forwarded to all running stream instances for this source. Accepts either 'colors' (flat [[R,G,B], ...] array) or 'segments' (segment-based).
The payload is forwarded to all running stream instances for this source.
""" """
try: try:
source = store.get_source(source_id) source = store.get_source(source_id)
@@ -420,15 +421,27 @@ async def push_colors(
if not isinstance(source, ApiInputColorStripSource): if not isinstance(source, ApiInputColorStripSource):
raise HTTPException(status_code=400, detail="Source is not an api_input type") raise HTTPException(status_code=400, detail="Source is not an api_input type")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
if body.segments is not None:
# Segment-based path
seg_dicts = [s.model_dump() for s in body.segments]
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
return {
"status": "ok",
"streams_updated": len(streams),
"segments_applied": len(body.segments),
}
else:
# Legacy flat colors path
colors_array = np.array(body.colors, dtype=np.uint8) colors_array = np.array(body.colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3: if colors_array.ndim != 2 or colors_array.shape[1] != 3:
raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets") raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams: for stream in streams:
if hasattr(stream, "push_colors"): if hasattr(stream, "push_colors"):
stream.push_colors(colors_array) stream.push_colors(colors_array)
return { return {
"status": "ok", "status": "ok",
"streams_updated": len(streams), "streams_updated": len(streams),
@@ -708,18 +721,41 @@ async def css_api_input_ws(
break break
if "text" in message: if "text" in message:
# JSON frame: {"colors": [[R,G,B], ...]} # JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
import json import json
try: try:
data = json.loads(message["text"]) data = json.loads(message["text"])
raw_colors = data.get("colors", []) except (json.JSONDecodeError, ValueError) as e:
await websocket.send_json({"error": str(e)})
continue
if "segments" in data:
# Segment-based path — validate and push
try:
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
except Exception as e:
await websocket.send_json({"error": f"Invalid segment: {e}"})
continue
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_segments"):
stream.push_segments(seg_dicts)
continue
elif "colors" in data:
try:
raw_colors = data["colors"]
colors_array = np.array(raw_colors, dtype=np.uint8) colors_array = np.array(raw_colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3: if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"}) await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue continue
except (json.JSONDecodeError, ValueError, TypeError) as e: except (ValueError, TypeError) as e:
await websocket.send_json({"error": str(e)}) await websocket.send_json({"error": str(e)})
continue continue
else:
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
continue
elif "bytes" in message: elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED) # Binary frame: raw RGBRGB... bytes (3 bytes per LED)
@@ -732,7 +768,7 @@ async def css_api_input_ws(
else: else:
continue continue
# Push to all running streams # Push to all running streams (colors_array path only reaches here)
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams: for stream in streams:
if hasattr(stream, "push_colors"): if hasattr(stream, "push_colors"):
@@ -799,6 +835,10 @@ async def test_color_strip_ws(
try: try:
from wled_controller.core.processing.composite_stream import CompositeColorStripStream from wled_controller.core.processing.composite_stream import CompositeColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
is_api_input = isinstance(stream, ApiInputColorStripStream)
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
# Send metadata as first message # Send metadata as first message
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
is_composite = isinstance(source, CompositeColorStripSource) is_composite = isinstance(source, CompositeColorStripSource)
@@ -874,6 +914,15 @@ async def test_color_strip_ws(
await websocket.send_bytes(b''.join(parts)) await websocket.send_bytes(b''.join(parts))
elif composite_colors is not None: elif composite_colors is not None:
await websocket.send_bytes(composite_colors.tobytes()) await websocket.send_bytes(composite_colors.tobytes())
else:
# For api_input: only send when new data was pushed
if is_api_input:
gen = stream.push_generation
if gen != _last_push_gen:
_last_push_gen = gen
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
else: else:
colors = stream.get_latest_colors() colors = stream.get_latest_colors()
if colors is not None: if colors is not None:

View File

@@ -3,7 +3,7 @@
from datetime import datetime from datetime import datetime
from typing import Dict, List, Literal, Optional from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from wled_controller.api.schemas.devices import Calibration from wled_controller.api.schemas.devices import Calibration
@@ -237,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
count: int = Field(description="Number of sources") count: int = Field(description="Number of sources")
class ColorPushRequest(BaseModel): class SegmentPayload(BaseModel):
"""Request to push raw LED colors to an api_input source.""" """A single segment for segment-based LED color updates."""
colors: List[List[int]] = Field(description="LED color array [[R,G,B], ...] (0-255 each)") start: int = Field(ge=0, description="Starting LED index")
length: int = Field(ge=1, description="Number of LEDs in segment")
mode: Literal["solid", "per_pixel", "gradient"] = Field(description="Fill mode")
color: Optional[List[int]] = Field(None, description="RGB for solid mode [R,G,B]")
colors: Optional[List[List[int]]] = Field(None, description="Colors for per_pixel/gradient [[R,G,B],...]")
@model_validator(mode="after")
def _validate_mode_fields(self) -> "SegmentPayload":
if self.mode == "solid":
if self.color is None or len(self.color) != 3:
raise ValueError("solid mode requires 'color' as a list of 3 ints [R,G,B]")
if not all(0 <= c <= 255 for c in self.color):
raise ValueError("solid color values must be 0-255")
elif self.mode == "per_pixel":
if not self.colors:
raise ValueError("per_pixel mode requires non-empty 'colors' list")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color in per_pixel must be [R,G,B]")
elif self.mode == "gradient":
if not self.colors or len(self.colors) < 2:
raise ValueError("gradient mode requires 'colors' with at least 2 stops")
for c in self.colors:
if len(c) != 3:
raise ValueError("each color stop in gradient must be [R,G,B]")
return self
class ColorPushRequest(BaseModel):
"""Request to push raw LED colors to an api_input source.
Accepts either 'colors' (legacy flat array) or 'segments' (new segment-based).
At least one must be provided.
"""
colors: Optional[List[List[int]]] = Field(None, description="LED color array [[R,G,B], ...] (0-255 each)")
segments: Optional[List[SegmentPayload]] = Field(None, description="Segment-based color updates")
@model_validator(mode="after")
def _require_colors_or_segments(self) -> "ColorPushRequest":
if self.colors is None and self.segments is None:
raise ValueError("Either 'colors' or 'segments' must be provided")
return self
class NotifyRequest(BaseModel): class NotifyRequest(BaseModel):

View File

@@ -4,9 +4,9 @@ External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`. received within `timeout` seconds, LEDs revert to `fallback_color`.
Thread-safe: push_colors() can be called from any thread (REST handler, Thread-safe: push_colors() / push_segments() can be called from any thread
WebSocket handler) while get_latest_colors() is called from the target (REST handler, WebSocket handler) while get_latest_colors() is called from
processor thread. the target processor thread.
""" """
import threading import threading
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
_DEFAULT_LED_COUNT = 150
class ApiInputColorStripStream(ColorStripStream): class ApiInputColorStripStream(ColorStripStream):
"""Color strip stream backed by externally-pushed LED color data. """Color strip stream backed by externally-pushed LED color data.
Holds a thread-safe np.ndarray buffer. External clients push colors via Holds a thread-safe np.ndarray buffer. External clients push colors via
push_colors(). A background thread checks for timeout and reverts to push_colors() or push_segments(). A background thread checks for timeout
fallback_color when no data arrives within the configured timeout window. and reverts to fallback_color when no data arrives within the configured
timeout window.
""" """
def __init__(self, source): def __init__(self, source):
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0) self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
self._auto_size = not source.led_count self._led_count = _DEFAULT_LED_COUNT
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
# Build initial fallback buffer # Build initial fallback buffer
self._fallback_array = self._build_fallback(self._led_count) self._fallback_array = self._build_fallback(self._led_count)
self._colors = self._fallback_array.copy() self._colors = self._fallback_array.copy()
self._last_push_time: float = 0.0 self._last_push_time: float = 0.0
self._timed_out = True # Start in timed-out state self._timed_out = True # Start in timed-out state
self._push_generation: int = 0 # Incremented on each push; used by test WS
def _build_fallback(self, led_count: int) -> np.ndarray: def _build_fallback(self, led_count: int) -> np.ndarray:
"""Build a (led_count, 3) uint8 array filled with fallback_color.""" """Build a (led_count, 3) uint8 array filled with fallback_color."""
@@ -59,40 +62,124 @@ class ApiInputColorStripStream(ColorStripStream):
(led_count, 1), (led_count, 1),
) )
def _ensure_capacity(self, required: int) -> None:
"""Grow the buffer to at least `required` LEDs (must be called under lock)."""
if required > self._led_count:
self._led_count = required
self._fallback_array = self._build_fallback(self._led_count)
# Preserve existing data if not timed out
if not self._timed_out:
new_buf = self._fallback_array.copy()
old_len = min(len(self._colors), required)
new_buf[:old_len] = self._colors[:old_len]
self._colors = new_buf
else:
self._colors = self._fallback_array.copy()
logger.debug(f"ApiInputColorStripStream buffer grown to {required} LEDs")
def push_colors(self, colors: np.ndarray) -> None: def push_colors(self, colors: np.ndarray) -> None:
"""Push a new frame of LED colors. """Push a new frame of LED colors.
Thread-safe. The array is truncated or zero-padded to match led_count. Thread-safe. Auto-grows the buffer if the incoming array is larger
than the current buffer; otherwise truncates or zero-pads.
Args: Args:
colors: np.ndarray shape (N, 3) uint8 colors: np.ndarray shape (N, 3) uint8
""" """
with self._lock: with self._lock:
n = len(colors) n = len(colors)
# Auto-grow if incoming data is larger
if n > self._led_count:
self._ensure_capacity(n)
if n == self._led_count: if n == self._led_count:
self._colors = colors.astype(np.uint8) self._colors = colors.astype(np.uint8)
elif n > self._led_count: elif n < self._led_count:
self._colors = colors[:self._led_count].astype(np.uint8)
else:
# Zero-pad to led_count # Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8) padded = np.zeros((self._led_count, 3), dtype=np.uint8)
padded[:n] = colors[:n] padded[:n] = colors[:n]
self._colors = padded self._colors = padded
self._last_push_time = time.monotonic() self._last_push_time = time.monotonic()
self._push_generation += 1
self._timed_out = False
def push_segments(self, segments: list) -> None:
"""Apply segment-based color updates to the buffer.
Each segment defines a range and fill mode. Segments are applied in
order (last wins on overlap). The buffer is auto-grown if needed.
Args:
segments: list of dicts with keys:
start (int) starting LED index
length (int) number of LEDs in segment
mode (str) "solid" | "per_pixel" | "gradient"
color (list) [R,G,B] for solid mode
colors (list) [[R,G,B], ...] for per_pixel/gradient
"""
# Compute required buffer size from all segments
max_index = max(seg["start"] + seg["length"] for seg in segments)
with self._lock:
# Auto-grow buffer if needed
if max_index > self._led_count:
self._ensure_capacity(max_index)
# Start from current buffer (or fallback if timed out)
if self._timed_out:
buf = self._fallback_array.copy()
else:
buf = self._colors.copy()
for seg in segments:
start = seg["start"]
length = seg["length"]
mode = seg["mode"]
end = start + length
if mode == "solid":
color = np.array(seg["color"], dtype=np.uint8)
buf[start:end] = color
elif mode == "per_pixel":
colors = np.array(seg["colors"], dtype=np.uint8)
available = len(colors)
if available >= length:
buf[start:end] = colors[:length]
else:
# Pad with zeros if fewer colors than length
buf[start:start + available] = colors
buf[start + available:end] = 0
elif mode == "gradient":
stops = np.array(seg["colors"], dtype=np.float32)
num_stops = len(stops)
# Positions of stops evenly spaced 0..length-1
stop_positions = np.linspace(0, length - 1, num_stops)
pixel_positions = np.arange(length, dtype=np.float32)
for ch in range(3):
buf[start:end, ch] = np.interp(
pixel_positions,
stop_positions,
stops[:, ch],
).astype(np.uint8)
self._colors = buf
self._last_push_time = time.monotonic()
self._push_generation += 1
self._timed_out = False self._timed_out = False
def configure(self, device_led_count: int) -> None: def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start). """Set LED count from the target device (called on target start).
Only takes effect when led_count was 0 (auto-size). Always resizes the buffer to the device LED count.
""" """
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: if device_led_count > 0 and device_led_count != self._led_count:
with self._lock: with self._lock:
self._led_count = device_led_count self._led_count = device_led_count
self._fallback_array = self._build_fallback(device_led_count) self._fallback_array = self._build_fallback(device_led_count)
self._colors = self._fallback_array.copy() self._colors = self._fallback_array.copy()
self._timed_out = True self._timed_out = True
logger.debug(f"ApiInputColorStripStream auto-sized to {device_led_count} LEDs") logger.debug(f"ApiInputColorStripStream configured to {device_led_count} LEDs")
@property @property
def target_fps(self) -> int: def target_fps(self) -> int:
@@ -131,6 +218,11 @@ class ApiInputColorStripStream(ColorStripStream):
with self._lock: with self._lock:
return self._colors return self._colors
@property
def push_generation(self) -> int:
"""Monotonically increasing counter, bumped on each push_colors/push_segments."""
return self._push_generation
def update_source(self, source) -> None: def update_source(self, source) -> None:
"""Hot-update fallback_color and timeout from updated source config.""" """Hot-update fallback_color and timeout from updated source config."""
from wled_controller.storage.color_strip_source import ApiInputColorStripSource from wled_controller.storage.color_strip_source import ApiInputColorStripSource
@@ -138,15 +230,6 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color fallback = source.fallback_color
self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0] self._fallback_color = fallback if isinstance(fallback, list) and len(fallback) == 3 else [0, 0, 0]
self._timeout = max(0.0, source.timeout if source.timeout else 5.0) self._timeout = max(0.0, source.timeout if source.timeout else 5.0)
prev_led_count = self._led_count if self._auto_size else None
self._auto_size = not source.led_count
with self._lock:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
self._colors = self._fallback_array.copy()
# Preserve runtime LED count across updates if auto-sized
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
with self._lock: with self._lock:
self._fallback_array = self._build_fallback(self._led_count) self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out: if self._timed_out:

View File

@@ -269,6 +269,8 @@
font-size: 0.9em; font-size: 0.9em;
} }
/* FPS chart for api_input test preview — reuses .target-fps-row from cards.css */
/* Composite layers preview */ /* Composite layers preview */
.css-test-layers { .css-test-layers {
display: flex; display: flex;
@@ -346,7 +348,107 @@
opacity: 1; opacity: 1;
} }
/* ── Log viewer ─────────────────────────────────────────────── */ /* ── Settings modal tabs ───────────────────────────────────── */
.settings-tab-bar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-color);
padding: 0 1.25rem;
}
.settings-tab-btn {
background: none;
border: none;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.2s ease, border-color 0.25s ease;
}
.settings-tab-btn:hover {
color: var(--text-color);
}
.settings-tab-btn.active {
color: var(--primary-text-color);
border-bottom-color: var(--primary-color);
}
.settings-panel {
display: none;
}
.settings-panel.active {
display: block;
animation: tabFadeIn 0.25s ease-out;
}
/* ── Log viewer overlay (full-screen) ──────────────────────── */
.log-overlay {
position: fixed;
inset: 0;
z-index: 2100;
display: flex;
flex-direction: column;
background: var(--bg-color, #111);
padding: 12px 16px;
animation: fadeIn 0.2s ease-out;
}
.log-overlay-close {
position: absolute;
top: 8px;
right: 12px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.3rem;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
z-index: 1;
transition: color 0.15s, background 0.15s;
}
.log-overlay-close:hover {
color: var(--text-color);
background: var(--border-color);
}
.log-overlay-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 10px;
padding-right: 36px; /* space for corner close btn */
flex-shrink: 0;
}
.log-overlay-toolbar h3 {
margin: 0;
font-size: 1rem;
white-space: nowrap;
margin-right: 4px;
}
.log-overlay .log-viewer-output {
flex: 1;
max-height: none;
border-radius: 8px;
min-height: 0;
}
/* ── Log viewer base ───────────────────────────────────────── */
.log-viewer-output { .log-viewer-output {
background: #0d0d0d; background: #0d0d0d;

View File

@@ -182,12 +182,14 @@ import { switchTab, initTabs, startAutoRefresh, handlePopState } from './feature
import { navigateToCard } from './core/navigation.js'; import { navigateToCard } from './core/navigation.js';
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js'; import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
import { import {
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected, openSettingsModal, closeSettingsModal, switchSettingsTab,
downloadBackup, handleRestoreFileSelected,
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup, saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
restartServer, saveMqttSettings, restartServer, saveMqttSettings,
loadApiKeysList, loadApiKeysList,
downloadPartialExport, handlePartialImportFileSelected, downloadPartialExport, handlePartialImportFileSelected,
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
openLogOverlay, closeLogOverlay,
loadLogLevel, setLogLevel, loadLogLevel, setLogLevel,
} from './features/settings.js'; } from './features/settings.js';
@@ -522,9 +524,10 @@ Object.assign(window, {
openCommandPalette, openCommandPalette,
closeCommandPalette, closeCommandPalette,
// settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level) // settings (tabs / backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
openSettingsModal, openSettingsModal,
closeSettingsModal, closeSettingsModal,
switchSettingsTab,
downloadBackup, downloadBackup,
handleRestoreFileSelected, handleRestoreFileSelected,
saveAutoBackupSettings, saveAutoBackupSettings,
@@ -540,6 +543,8 @@ Object.assign(window, {
disconnectLogViewer, disconnectLogViewer,
clearLogViewer, clearLogViewer,
applyLogFilter, applyLogFilter,
openLogOverlay,
closeLogOverlay,
loadLogLevel, loadLogLevel,
setLogLevel, setLogLevel,
}); });
@@ -569,8 +574,11 @@ document.addEventListener('keydown', (e) => {
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
// Close in order: overlay lightboxes first, then modals via stack // Close in order: log overlay > overlay lightboxes > modals via stack
if (document.getElementById('display-picker-lightbox').classList.contains('active')) { const logOverlay = document.getElementById('log-overlay');
if (logOverlay && logOverlay.style.display !== 'none') {
closeLogOverlay();
} else if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
closeDisplayPicker(); closeDisplayPicker();
} else if (document.getElementById('image-lightbox').classList.contains('active')) { } else if (document.getElementById('image-lightbox').classList.contains('active')) {
closeLightbox(); closeLightbox();

View File

@@ -30,11 +30,51 @@ const ENTITY_CACHE_MAP = {
pattern_template: patternTemplatesCache, pattern_template: patternTemplatesCache,
}; };
/** Maps entity_type to the window load function that refreshes its UI. */
const ENTITY_LOADER_MAP = {
device: 'loadTargetsTab',
output_target: 'loadTargetsTab',
color_strip_source: 'loadTargetsTab',
pattern_template: 'loadTargetsTab',
picture_source: 'loadPictureSources',
audio_source: 'loadPictureSources',
value_source: 'loadPictureSources',
sync_clock: 'loadPictureSources',
capture_template: 'loadPictureSources',
audio_template: 'loadPictureSources',
pp_template: 'loadPictureSources',
automation: 'loadAutomations',
scene_preset: 'loadAutomations',
};
/** Debounce timers per loader function name — coalesces rapid WS events and
* avoids a redundant re-render when the local save handler already triggered one. */
const _loaderTimers = {};
const _LOADER_DEBOUNCE_MS = 600;
function _invalidateAndReload(entityType) { function _invalidateAndReload(entityType) {
const cache = ENTITY_CACHE_MAP[entityType]; const cache = ENTITY_CACHE_MAP[entityType];
if (cache) { if (!cache) return;
cache.fetch({ force: true });
const oldData = cache.data;
cache.fetch({ force: true }).then((newData) => {
// Skip UI refresh if the data didn't actually change —
// the local save handler already refreshed the UI.
if (oldData === newData) return;
if (Array.isArray(oldData) && Array.isArray(newData) &&
oldData.length === newData.length &&
JSON.stringify(oldData) === JSON.stringify(newData)) return;
const loader = ENTITY_LOADER_MAP[entityType];
if (loader) {
clearTimeout(_loaderTimers[loader]);
_loaderTimers[loader] = setTimeout(() => {
delete _loaderTimers[loader];
if (typeof window[loader] === 'function') window[loader]();
}, _LOADER_DEBOUNCE_MS);
} }
});
document.dispatchEvent(new CustomEvent('entity:reload', { document.dispatchEvent(new CustomEvent('entity:reload', {
detail: { entity_type: entityType }, detail: { entity_type: entityType },
})); }));

View File

@@ -10,7 +10,7 @@
* This module manages the editor modal and API operations. * This module manages the editor modal and API operations.
*/ */
import { _cachedAudioSources, _cachedAudioTemplates, apiKey } from '../core/state.js'; import { _cachedAudioSources, _cachedAudioTemplates, apiKey, audioSourcesCache } from '../core/state.js';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js'; import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
@@ -155,6 +155,7 @@ export async function saveAudioSource() {
} }
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success'); showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
audioSourceModal.forceClose(); audioSourceModal.forceClose();
audioSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
errorEl.textContent = e.message; errorEl.textContent = e.message;
@@ -205,6 +206,7 @@ export async function deleteAudioSource(sourceId) {
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('audio_source.deleted'), 'success'); showToast(t('audio_source.deleted'), 'success');
audioSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');

View File

@@ -706,6 +706,7 @@ export async function saveAutomationEditor() {
automationModal.forceClose(); automationModal.forceClose();
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success'); showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -720,6 +721,7 @@ export async function toggleAutomationEnabled(automationId, enable) {
method: 'POST', method: 'POST',
}); });
if (!resp.ok) throw new Error(`Failed to ${action} automation`); if (!resp.ok) throw new Error(`Failed to ${action} automation`);
automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -767,6 +769,7 @@ export async function deleteAutomation(automationId, automationName) {
}); });
if (!resp.ok) throw new Error('Failed to delete automation'); if (!resp.ok) throw new Error('Failed to delete automation');
showToast(t('automations.deleted'), 'success'); showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;

View File

@@ -3,6 +3,7 @@
*/ */
import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { createFpsSparkline } from '../core/chart-utils.js';
import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js'; import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js'; import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
@@ -198,8 +199,8 @@ export function onCSSTypeChange() {
} }
_syncAnimationSpeedState(); _syncAnimationSpeedState();
// LED count — only shown for picture, picture_advanced, api_input // LED count — only shown for picture, picture_advanced
const hasLedCount = ['picture', 'picture_advanced', 'api_input']; const hasLedCount = ['picture', 'picture_advanced'];
document.getElementById('css-editor-led-count-group').style.display = document.getElementById('css-editor-led-count-group').style.display =
hasLedCount.includes(type) ? '' : 'none'; hasLedCount.includes(type) ? '' : 'none';
@@ -1656,9 +1657,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const notifHistoryBtn = isNotification const notifHistoryBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>` ? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
: ''; : '';
const testPreviewBtn = !isApiInput const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
: '';
return wrapCard({ return wrapCard({
dataAttr: 'data-css-id', dataAttr: 'data-css-id',
@@ -2259,6 +2258,7 @@ export async function saveCSSEditor() {
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success'); showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
colorStripSourcesCache.invalidate(); colorStripSourcesCache.invalidate();
cssEditorModal.forceClose(); cssEditorModal.forceClose();
if (window.loadPictureSources) window.loadPictureSources();
if (window.loadTargetsTab) await window.loadTargetsTab(); if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
@@ -2335,6 +2335,7 @@ export async function deleteColorStrip(cssId) {
if (response.ok) { if (response.ok) {
showToast(t('color_strip.deleted'), 'success'); showToast(t('color_strip.deleted'), 'success');
colorStripSourcesCache.invalidate(); colorStripSourcesCache.invalidate();
if (window.loadPictureSources) window.loadPictureSources();
if (window.loadTargetsTab) await window.loadTargetsTab(); if (window.loadTargetsTab) await window.loadTargetsTab();
} else { } else {
const err = await response.json(); const err = await response.json();
@@ -2476,6 +2477,11 @@ let _cssTestGeneration = 0; // bumped on each connect to ignore stale WS messa
let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers) let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers)
let _cssTestCSPTMode = false; // true when testing a CSPT template let _cssTestCSPTMode = false; // true when testing a CSPT template
let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode
let _cssTestIsApiInput = false;
let _cssTestFpsTimestamps = []; // raw timestamps for current-second FPS calculation
let _cssTestFpsActualHistory = []; // rolling FPS samples for sparkline
let _cssTestFpsChart = null;
const _CSS_TEST_FPS_MAX_SAMPLES = 30;
let _csptTestInputEntitySelect = null; let _csptTestInputEntitySelect = null;
function _getCssTestLedCount() { function _getCssTestLedCount() {
@@ -2488,12 +2494,32 @@ function _getCssTestFps() {
return (stored >= 1 && stored <= 60) ? stored : 20; return (stored >= 1 && stored <= 60) ? stored : 20;
} }
function _populateCssTestSourceSelector(preselectId) {
const sources = colorStripSourcesCache.data || [];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
const sel = document.getElementById('css-test-cspt-input-select');
sel.innerHTML = nonProcessed.map(s =>
`<option value="${s.id}"${s.id === preselectId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
_csptTestInputEntitySelect = new EntitySelect({
target: sel,
getItems: () => (colorStripSourcesCache.data || [])
.filter(s => s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
}
export function testColorStrip(sourceId) { export function testColorStrip(sourceId) {
_cssTestCSPTMode = false; _cssTestCSPTMode = false;
_cssTestCSPTId = null; _cssTestCSPTId = null;
// Hide CSPT input selector // Detect api_input type
const csptGroup = document.getElementById('css-test-cspt-input-group'); const sources = colorStripSourcesCache.data || [];
if (csptGroup) csptGroup.style.display = 'none'; const src = sources.find(s => s.id === sourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
// Populate input source selector with current source preselected
_populateCssTestSourceSelector(sourceId);
_openTestModal(sourceId); _openTestModal(sourceId);
} }
@@ -2503,25 +2529,9 @@ export async function testCSPT(templateId) {
// Populate input source selector // Populate input source selector
await colorStripSourcesCache.fetch(); await colorStripSourcesCache.fetch();
const sources = colorStripSourcesCache.data || []; _populateCssTestSourceSelector(null);
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
const sel = document.getElementById('css-test-cspt-input-select');
sel.innerHTML = nonProcessed.map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// EntitySelect for input source picker
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
_csptTestInputEntitySelect = new EntitySelect({
target: sel,
getItems: () => (colorStripSourcesCache.data || [])
.filter(s => s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
// Show CSPT input selector
const csptGroup = document.getElementById('css-test-cspt-input-group');
if (csptGroup) csptGroup.style.display = '';
const sel = document.getElementById('css-test-cspt-input-select');
const inputId = sel.value; const inputId = sel.value;
if (!inputId) { if (!inputId) {
showToast(t('color_strip.processed.error.no_input'), 'error'); showToast(t('color_strip.processed.error.no_input'), 'error');
@@ -2550,11 +2560,29 @@ function _openTestModal(sourceId) {
document.getElementById('css-test-rect-view').style.display = 'none'; document.getElementById('css-test-rect-view').style.display = 'none';
document.getElementById('css-test-layers-view').style.display = 'none'; document.getElementById('css-test-layers-view').style.display = 'none';
document.getElementById('css-test-led-group').style.display = ''; document.getElementById('css-test-led-group').style.display = '';
// Input source selector: shown for both CSS test and CSPT test, hidden for api_input
const csptGroup = document.getElementById('css-test-cspt-input-group');
if (csptGroup) csptGroup.style.display = _cssTestIsApiInput ? 'none' : '';
const layersContainer = document.getElementById('css-test-layers'); const layersContainer = document.getElementById('css-test-layers');
if (layersContainer) layersContainer.innerHTML = ''; if (layersContainer) layersContainer.innerHTML = '';
document.getElementById('css-test-status').style.display = ''; document.getElementById('css-test-status').style.display = '';
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting'); document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
// Reset FPS tracking
_cssTestFpsHistory = [];
// For api_input: hide LED/FPS controls, show FPS chart
const ledControlGroup = document.getElementById('css-test-led-fps-group');
const fpsChartGroup = document.getElementById('css-test-fps-chart-group');
if (_cssTestIsApiInput) {
if (ledControlGroup) ledControlGroup.style.display = 'none';
if (fpsChartGroup) fpsChartGroup.style.display = '';
_cssTestStartFpsSampling();
// Use large LED count (buffer auto-sizes) and high poll FPS
_cssTestConnect(sourceId, 1000, 60);
} else {
if (ledControlGroup) ledControlGroup.style.display = '';
if (fpsChartGroup) fpsChartGroup.style.display = 'none';
// Restore LED count + FPS + Enter key handlers // Restore LED count + FPS + Enter key handlers
const ledCount = _getCssTestLedCount(); const ledCount = _getCssTestLedCount();
const ledInput = document.getElementById('css-test-led-input'); const ledInput = document.getElementById('css-test-led-input');
@@ -2567,6 +2595,7 @@ function _openTestModal(sourceId) {
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } }; fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
_cssTestConnect(sourceId, ledCount, fpsVal); _cssTestConnect(sourceId, ledCount, fpsVal);
}
} }
function _cssTestConnect(sourceId, ledCount, fps) { function _cssTestConnect(sourceId, ledCount, fps) {
@@ -2714,6 +2743,11 @@ function _cssTestConnect(sourceId, ledCount, fps) {
// Standard format: raw RGB // Standard format: raw RGB
_cssTestLatestRgb = raw; _cssTestLatestRgb = raw;
} }
// Track FPS for api_input sources
if (_cssTestIsApiInput) {
_cssTestFpsTimestamps.push(performance.now());
}
} }
}; };
@@ -2805,12 +2839,14 @@ export function applyCssTestSettings() {
_cssTestMeta = null; _cssTestMeta = null;
_cssTestLayerData = null; _cssTestLayerData = null;
// In CSPT mode, read selected input source // Read selected input source from selector (both CSS and CSPT modes)
if (_cssTestCSPTMode) {
const inputSel = document.getElementById('css-test-cspt-input-select'); const inputSel = document.getElementById('css-test-cspt-input-select');
if (inputSel && inputSel.value) { if (inputSel && inputSel.value) {
_cssTestSourceId = inputSel.value; _cssTestSourceId = inputSel.value;
} // Re-detect api_input when source changes
const sources = colorStripSourcesCache.data || [];
const src = sources.find(s => s.id === _cssTestSourceId);
_cssTestIsApiInput = src?.source_type === 'api_input';
} }
// Reconnect (generation counter ignores stale frames from old WS) // Reconnect (generation counter ignores stale frames from old WS)
@@ -3162,6 +3198,60 @@ export function fireCssTestNotificationLayer(sourceId) {
testNotification(sourceId); testNotification(sourceId);
} }
let _cssTestFpsSampleInterval = null;
function _cssTestStartFpsSampling() {
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
// Sample FPS every 1 second
_cssTestFpsSampleInterval = setInterval(() => {
const now = performance.now();
// Count frames in the last 1 second
const cutoff = now - 1000;
_cssTestFpsTimestamps = _cssTestFpsTimestamps.filter(t => t >= cutoff);
const fps = _cssTestFpsTimestamps.length;
_cssTestFpsActualHistory.push(fps);
if (_cssTestFpsActualHistory.length > _CSS_TEST_FPS_MAX_SAMPLES)
_cssTestFpsActualHistory.shift();
// Update numeric display (match target card format)
const valueEl = document.getElementById('css-test-fps-value');
if (valueEl) valueEl.textContent = fps;
const avgEl = document.getElementById('css-test-fps-avg');
if (avgEl && _cssTestFpsActualHistory.length > 1) {
const avg = _cssTestFpsActualHistory.reduce((a, b) => a + b, 0) / _cssTestFpsActualHistory.length;
avgEl.textContent = `avg ${avg.toFixed(1)}`;
}
// Create or update chart
if (!_cssTestFpsChart) {
_cssTestFpsChart = createFpsSparkline(
'css-test-fps-chart',
_cssTestFpsActualHistory,
[], // no "current" dataset, just actual
60, // y-axis max
);
}
if (_cssTestFpsChart) {
const ds = _cssTestFpsChart.data.datasets[0].data;
ds.length = 0;
ds.push(..._cssTestFpsActualHistory);
while (_cssTestFpsChart.data.labels.length < ds.length) _cssTestFpsChart.data.labels.push('');
_cssTestFpsChart.data.labels.length = ds.length;
_cssTestFpsChart.update('none');
}
}, 1000);
}
function _cssTestStopFpsSampling() {
if (_cssTestFpsSampleInterval) { clearInterval(_cssTestFpsSampleInterval); _cssTestFpsSampleInterval = null; }
if (_cssTestFpsChart) { _cssTestFpsChart.destroy(); _cssTestFpsChart = null; }
}
export function closeTestCssSourceModal() { export function closeTestCssSourceModal() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
@@ -3171,6 +3261,10 @@ export function closeTestCssSourceModal() {
_cssTestIsComposite = false; _cssTestIsComposite = false;
_cssTestLayerData = null; _cssTestLayerData = null;
_cssTestNotificationIds = []; _cssTestNotificationIds = [];
_cssTestIsApiInput = false;
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
// Revoke blob URL for frame preview // Revoke blob URL for frame preview
const screen = document.getElementById('css-test-rect-screen'); const screen = document.getElementById('css-test-rect-screen');
if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; } if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }

View File

@@ -243,6 +243,7 @@ export async function saveScenePreset() {
scenePresetModal.forceClose(); scenePresetModal.forceClose();
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success'); showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
@@ -348,6 +349,7 @@ export async function recaptureScenePreset(presetId) {
}); });
if (resp.ok) { if (resp.ok) {
showToast(t('scenes.recaptured'), 'success'); showToast(t('scenes.recaptured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} else { } else {
showToast(t('scenes.error.recapture_failed'), 'error'); showToast(t('scenes.error.recapture_failed'), 'error');
@@ -420,6 +422,7 @@ export async function deleteScenePreset(presetId) {
}); });
if (resp.ok) { if (resp.ok) {
showToast(t('scenes.deleted'), 'success'); showToast(t('scenes.deleted'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} else { } else {
showToast(t('scenes.error.delete_failed'), 'error'); showToast(t('scenes.error.delete_failed'), 'error');

View File

@@ -1,5 +1,5 @@
/** /**
* Settings — backup / restore configuration. * Settings — tabbed modal (General / Backup / MQTT) + full-screen Log overlay.
*/ */
import { apiKey } from '../core/state.js'; import { apiKey } from '../core/state.js';
@@ -10,6 +10,17 @@ import { t } from '../core/i18n.js';
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js'; import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
import { IconSelect } from '../core/icon-select.js'; import { IconSelect } from '../core/icon-select.js';
// ─── Settings-modal tab switching ───────────────────────────
export function switchSettingsTab(tabId) {
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.settingsTab === tabId);
});
document.querySelectorAll('.settings-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
});
}
// ─── Log Viewer ──────────────────────────────────────────── // ─── Log Viewer ────────────────────────────────────────────
/** @type {WebSocket|null} */ /** @type {WebSocket|null} */
@@ -114,9 +125,6 @@ export function clearLogViewer() {
/** Re-render the log output according to the current filter selection. */ /** Re-render the log output according to the current filter selection. */
export function applyLogFilter() { export function applyLogFilter() {
// We don't buffer all raw lines in JS — just clear and note the filter
// will apply to future lines. Existing lines that were already rendered
// are re-evaluated by toggling their visibility.
const output = document.getElementById('log-viewer-output'); const output = document.getElementById('log-viewer-output');
if (!output) return; if (!output) return;
const filter = _filterLevel(); const filter = _filterLevel();
@@ -128,50 +136,84 @@ export function applyLogFilter() {
} }
} }
// Simple modal (no form / no dirty check needed) // ─── Log Overlay (full-screen) ──────────────────────────────
const settingsModal = new Modal('settings-modal');
let _logFilterIconSelect = null; let _logFilterIconSelect = null;
let _logLevelIconSelect = null;
const _LOG_LEVEL_ITEMS = [ /** Build filter items lazily so t() has locale data loaded. */
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') }, function _getLogFilterItems() {
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') }, return [
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
];
const _LOG_FILTER_ITEMS = [
{ value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') }, { value: 'all', icon: '<span style="color:#9e9e9e;font-weight:700">*</span>', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') },
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') }, { value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') },
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') }, { value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') },
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') }, { value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
]; ];
}
export function openSettingsModal() { export function openLogOverlay() {
document.getElementById('settings-error').style.display = 'none'; const overlay = document.getElementById('log-overlay');
settingsModal.open(); if (overlay) {
overlay.style.display = 'flex';
// Initialize log filter icon select // Initialize log filter icon select (once)
if (!_logFilterIconSelect) { if (!_logFilterIconSelect) {
const filterSel = document.getElementById('log-viewer-filter'); const filterSel = document.getElementById('log-viewer-filter');
if (filterSel) { if (filterSel) {
_logFilterIconSelect = new IconSelect({ _logFilterIconSelect = new IconSelect({
target: filterSel, target: filterSel,
items: _LOG_FILTER_ITEMS, items: _getLogFilterItems(),
columns: 2, columns: 2,
onChange: () => applyLogFilter(), onChange: () => applyLogFilter(),
}); });
} }
} }
// Auto-connect when opening
if (!_logWs || _logWs.readyState !== WebSocket.OPEN) {
connectLogViewer();
}
}
}
export function closeLogOverlay() {
const overlay = document.getElementById('log-overlay');
if (overlay) overlay.style.display = 'none';
disconnectLogViewer();
}
// ─── Settings Modal ─────────────────────────────────────────
// Simple modal (no form / no dirty check needed)
const settingsModal = new Modal('settings-modal');
let _logLevelIconSelect = null;
/** Build log-level items lazily so t() has locale data loaded. */
function _getLogLevelItems() {
return [
{ value: 'DEBUG', icon: '<span style="color:#9e9e9e;font-weight:700">D</span>', label: 'DEBUG', desc: t('settings.log_level.desc.debug') },
{ value: 'INFO', icon: '<span style="color:#4fc3f7;font-weight:700">I</span>', label: 'INFO', desc: t('settings.log_level.desc.info') },
{ value: 'WARNING', icon: '<span style="color:#ffb74d;font-weight:700">W</span>', label: 'WARNING', desc: t('settings.log_level.desc.warning') },
{ value: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: 'ERROR', desc: t('settings.log_level.desc.error') },
{ value: 'CRITICAL', icon: '<span style="color:#ff1744;font-weight:700">!</span>', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') },
];
}
export function openSettingsModal() {
document.getElementById('settings-error').style.display = 'none';
// Reset to first tab
switchSettingsTab('general');
settingsModal.open();
// Initialize log level icon select // Initialize log level icon select
if (!_logLevelIconSelect) { if (!_logLevelIconSelect) {
const levelSel = document.getElementById('settings-log-level'); const levelSel = document.getElementById('settings-log-level');
if (levelSel) { if (levelSel) {
_logLevelIconSelect = new IconSelect({ _logLevelIconSelect = new IconSelect({
target: levelSel, target: levelSel,
items: _LOG_LEVEL_ITEMS, items: _getLogLevelItems(),
columns: 3, columns: 3,
onChange: () => setLogLevel(), onChange: () => setLogLevel(),
}); });
@@ -186,7 +228,6 @@ export function openSettingsModal() {
} }
export function closeSettingsModal() { export function closeSettingsModal() {
disconnectLogViewer();
settingsModal.forceClose(); settingsModal.forceClose();
} }

View File

@@ -710,6 +710,7 @@ export async function saveTemplate() {
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success'); showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
templateModal.forceClose(); templateModal.forceClose();
captureTemplatesCache.invalidate();
await loadCaptureTemplates(); await loadCaptureTemplates();
} catch (error) { } catch (error) {
console.error('Error saving template:', error); console.error('Error saving template:', error);
@@ -729,6 +730,7 @@ export async function deleteTemplate(templateId) {
throw new Error(error.detail || error.message || 'Failed to delete template'); throw new Error(error.detail || error.message || 'Failed to delete template');
} }
showToast(t('templates.deleted'), 'success'); showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate();
await loadCaptureTemplates(); await loadCaptureTemplates();
} catch (error) { } catch (error) {
console.error('Error deleting template:', error); console.error('Error deleting template:', error);
@@ -970,6 +972,7 @@ export async function saveAudioTemplate() {
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success'); showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
audioTemplateModal.forceClose(); audioTemplateModal.forceClose();
audioTemplatesCache.invalidate();
await loadAudioTemplates(); await loadAudioTemplates();
} catch (error) { } catch (error) {
console.error('Error saving audio template:', error); console.error('Error saving audio template:', error);
@@ -989,6 +992,7 @@ export async function deleteAudioTemplate(templateId) {
throw new Error(error.detail || error.message || 'Failed to delete audio template'); throw new Error(error.detail || error.message || 'Failed to delete audio template');
} }
showToast(t('audio_template.deleted'), 'success'); showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate();
await loadAudioTemplates(); await loadAudioTemplates();
} catch (error) { } catch (error) {
console.error('Error deleting audio template:', error); console.error('Error deleting audio template:', error);
@@ -2089,6 +2093,7 @@ export async function saveStream() {
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success'); showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
streamModal.forceClose(); streamModal.forceClose();
streamsCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (error) { } catch (error) {
console.error('Error saving stream:', error); console.error('Error saving stream:', error);
@@ -2108,6 +2113,7 @@ export async function deleteStream(streamId) {
throw new Error(error.detail || error.message || 'Failed to delete stream'); throw new Error(error.detail || error.message || 'Failed to delete stream');
} }
showToast(t('streams.deleted'), 'success'); showToast(t('streams.deleted'), 'success');
streamsCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (error) { } catch (error) {
console.error('Error deleting stream:', error); console.error('Error deleting stream:', error);
@@ -2675,6 +2681,7 @@ export async function savePPTemplate() {
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success'); showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
ppTemplateModal.forceClose(); ppTemplateModal.forceClose();
ppTemplatesCache.invalidate();
await loadPPTemplates(); await loadPPTemplates();
} catch (error) { } catch (error) {
console.error('Error saving PP template:', error); console.error('Error saving PP template:', error);
@@ -2735,6 +2742,7 @@ export async function deletePPTemplate(templateId) {
throw new Error(error.detail || error.message || 'Failed to delete template'); throw new Error(error.detail || error.message || 'Failed to delete template');
} }
showToast(t('postprocessing.deleted'), 'success'); showToast(t('postprocessing.deleted'), 'success');
ppTemplatesCache.invalidate();
await loadPPTemplates(); await loadPPTemplates();
} catch (error) { } catch (error) {
console.error('Error deleting PP template:', error); console.error('Error deleting PP template:', error);
@@ -2888,6 +2896,7 @@ export async function saveCSPT() {
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success'); showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
csptModal.forceClose(); csptModal.forceClose();
csptCache.invalidate();
await loadCSPTemplates(); await loadCSPTemplates();
} catch (error) { } catch (error) {
console.error('Error saving CSPT:', error); console.error('Error saving CSPT:', error);
@@ -2920,6 +2929,7 @@ export async function deleteCSPT(templateId) {
throw new Error(error.detail || error.message || 'Failed to delete template'); throw new Error(error.detail || error.message || 'Failed to delete template');
} }
showToast(t('css_processing.deleted'), 'success'); showToast(t('css_processing.deleted'), 'success');
csptCache.invalidate();
await loadCSPTemplates(); await loadCSPTemplates();
} catch (error) { } catch (error) {
console.error('Error deleting CSPT:', error); console.error('Error deleting CSPT:', error);

View File

@@ -98,6 +98,7 @@ export async function saveSyncClock() {
} }
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success'); showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
syncClockModal.forceClose(); syncClockModal.forceClose();
syncClocksCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -143,6 +144,7 @@ export async function deleteSyncClock(clockId) {
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('sync_clock.deleted'), 'success'); showToast(t('sync_clock.deleted'), 'success');
syncClocksCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;

View File

@@ -10,7 +10,7 @@
* This module manages the editor modal and API operations. * This module manages the editor modal and API operations.
*/ */
import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey } from '../core/state.js'; import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache } from '../core/state.js';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js'; import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
@@ -486,6 +486,7 @@ export async function saveValueSource() {
} }
showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success'); showToast(t(id ? 'value_source.updated' : 'value_source.created'), 'success');
valueSourceModal.forceClose(); valueSourceModal.forceClose();
valueSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
errorEl.textContent = e.message; errorEl.textContent = e.message;
@@ -536,6 +537,7 @@ export async function deleteValueSource(sourceId) {
throw new Error(err.detail || `HTTP ${resp.status}`); throw new Error(err.detail || `HTTP ${resp.status}`);
} }
showToast(t('value_source.deleted'), 'success'); showToast(t('value_source.deleted'), 'success');
valueSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');

View File

@@ -312,6 +312,10 @@
"device.tip.webui": "Open the device's built-in web interface for advanced configuration", "device.tip.webui": "Open the device's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new LED device", "device.tip.add": "Click here to add a new LED device",
"settings.title": "Settings", "settings.title": "Settings",
"settings.tab.general": "General",
"settings.tab.backup": "Backup",
"settings.tab.mqtt": "MQTT",
"settings.logs.open_viewer": "Open Log Viewer",
"settings.general.title": "General Settings", "settings.general.title": "General Settings",
"settings.capture.title": "Capture Settings", "settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated", "settings.capture.saved": "Capture settings updated",
@@ -1076,6 +1080,7 @@
"color_strip.test.error": "Failed to connect to preview stream", "color_strip.test.error": "Failed to connect to preview stream",
"color_strip.test.led_count": "LEDs:", "color_strip.test.led_count": "LEDs:",
"color_strip.test.fps": "FPS:", "color_strip.test.fps": "FPS:",
"color_strip.test.receive_fps": "Receive FPS",
"color_strip.test.apply": "Apply", "color_strip.test.apply": "Apply",
"color_strip.test.composite": "Composite", "color_strip.test.composite": "Composite",
"color_strip.preview.title": "Live Preview", "color_strip.preview.title": "Live Preview",

View File

@@ -312,6 +312,10 @@
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки", "device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство", "device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
"settings.title": "Настройки", "settings.title": "Настройки",
"settings.tab.general": "Основные",
"settings.tab.backup": "Бэкап",
"settings.tab.mqtt": "MQTT",
"settings.logs.open_viewer": "Открыть логи",
"settings.general.title": "Основные Настройки", "settings.general.title": "Основные Настройки",
"settings.capture.title": "Настройки Захвата", "settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены", "settings.capture.saved": "Настройки захвата обновлены",
@@ -1076,6 +1080,7 @@
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра", "color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
"color_strip.test.led_count": "Кол-во LED:", "color_strip.test.led_count": "Кол-во LED:",
"color_strip.test.fps": "FPS:", "color_strip.test.fps": "FPS:",
"color_strip.test.receive_fps": "Частота приёма",
"color_strip.test.apply": "Применить", "color_strip.test.apply": "Применить",
"color_strip.test.composite": "Композит", "color_strip.test.composite": "Композит",
"color_strip.preview.title": "Предпросмотр", "color_strip.preview.title": "Предпросмотр",

View File

@@ -312,6 +312,10 @@
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置", "device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
"device.tip.add": "点击此处添加新的 LED 设备", "device.tip.add": "点击此处添加新的 LED 设备",
"settings.title": "设置", "settings.title": "设置",
"settings.tab.general": "常规",
"settings.tab.backup": "备份",
"settings.tab.mqtt": "MQTT",
"settings.logs.open_viewer": "打开日志查看器",
"settings.general.title": "常规设置", "settings.general.title": "常规设置",
"settings.capture.title": "采集设置", "settings.capture.title": "采集设置",
"settings.capture.saved": "采集设置已更新", "settings.capture.saved": "采集设置已更新",
@@ -1076,6 +1080,7 @@
"color_strip.test.error": "无法连接到预览流", "color_strip.test.error": "无法连接到预览流",
"color_strip.test.led_count": "LED数量:", "color_strip.test.led_count": "LED数量:",
"color_strip.test.fps": "FPS:", "color_strip.test.fps": "FPS:",
"color_strip.test.receive_fps": "接收帧率",
"color_strip.test.apply": "应用", "color_strip.test.apply": "应用",
"color_strip.test.composite": "合成", "color_strip.test.composite": "合成",
"color_strip.preview.title": "实时预览", "color_strip.preview.title": "实时预览",

View File

@@ -792,16 +792,14 @@ class ApiInputColorStripSource(ColorStripSource):
External clients push [R,G,B] arrays via REST POST or WebSocket. The stream External clients push [R,G,B] arrays via REST POST or WebSocket. The stream
buffers the latest frame and serves it to targets. When no data has been buffers the latest frame and serves it to targets. When no data has been
received within `timeout` seconds, LEDs revert to `fallback_color`. received within `timeout` seconds, LEDs revert to `fallback_color`.
LED count auto-sizes from the connected device when led_count == 0. LED count auto-sizes from the connected device via configure().
""" """
led_count: int = 0 # 0 = auto-size from device
fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B] fallback_color: list = field(default_factory=lambda: [0, 0, 0]) # [R, G, B]
timeout: float = 5.0 # seconds before reverting to fallback timeout: float = 5.0 # seconds before reverting to fallback
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["led_count"] = self.led_count
d["fallback_color"] = list(self.fallback_color) d["fallback_color"] = list(self.fallback_color)
d["timeout"] = self.timeout d["timeout"] = self.timeout
return d return d
@@ -810,14 +808,14 @@ class ApiInputColorStripSource(ColorStripSource):
def create_from_kwargs(cls, *, id: str, name: str, source_type: str, def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
created_at: datetime, updated_at: datetime, created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None, description=None, clock_id=None, tags=None,
led_count=0, fallback_color=None, timeout=None, fallback_color=None, timeout=None,
**_kwargs): **_kwargs):
fb = _validate_rgb(fallback_color, [0, 0, 0]) fb = _validate_rgb(fallback_color, [0, 0, 0])
return cls( return cls(
id=id, name=name, source_type="api_input", id=id, name=name, source_type="api_input",
created_at=created_at, updated_at=updated_at, created_at=created_at, updated_at=updated_at,
description=description, clock_id=clock_id, tags=tags or [], description=description, clock_id=clock_id, tags=tags or [],
led_count=led_count, fallback_color=fb, fallback_color=fb,
timeout=float(timeout) if timeout is not None else 5.0, timeout=float(timeout) if timeout is not None else 5.0,
) )
@@ -827,8 +825,6 @@ class ApiInputColorStripSource(ColorStripSource):
self.fallback_color = fallback_color self.fallback_color = fallback_color
if kwargs.get("timeout") is not None: if kwargs.get("timeout") is not None:
self.timeout = float(kwargs["timeout"]) self.timeout = float(kwargs["timeout"])
if kwargs.get("led_count") is not None:
self.led_count = kwargs["led_count"]
@dataclass @dataclass

View File

@@ -1,11 +1,21 @@
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title"> <div id="settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
<div class="modal-content" style="max-width: 450px;"> <div class="modal-content" style="max-width: 480px;">
<div class="modal-header"> <div class="modal-header">
<h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2> <h2 id="settings-modal-title" data-i18n="settings.title">Settings</h2>
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<!-- Tab bar -->
<div class="settings-tab-bar">
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
</div>
<div class="modal-body"> <div class="modal-body">
<!-- ═══ General tab ═══ -->
<div id="settings-panel-general" class="settings-panel active">
<!-- API Keys section (read-only) --> <!-- API Keys section (read-only) -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -16,6 +26,41 @@
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div> <div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
</div> </div>
<!-- Log Level section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.log_level.label">Log Level</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
<select id="settings-log-level">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<!-- Server Logs button (opens overlay) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.logs.label">Server Logs</label>
</div>
<button class="btn btn-secondary" onclick="openLogOverlay()" style="width:100%" data-i18n="settings.logs.open_viewer">Open Log Viewer</button>
</div>
<!-- Restart section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restart_server">Restart Server</label>
</div>
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
</div>
</div>
<!-- ═══ Backup tab ═══ -->
<div id="settings-panel-backup" class="settings-panel">
<!-- Backup section --> <!-- Backup section -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
@@ -119,8 +164,10 @@
<small class="input-hint" style="display:none" data-i18n="settings.saved_backups.hint">Auto-backup files stored on the server. Download to save locally, or delete to free space.</small> <small class="input-hint" style="display:none" data-i18n="settings.saved_backups.hint">Auto-backup files stored on the server. Download to save locally, or delete to free space.</small>
<div id="saved-backups-list"></div> <div id="saved-backups-list"></div>
</div> </div>
</div>
<!-- MQTT section --> <!-- ═══ MQTT tab ═══ -->
<div id="settings-panel-mqtt" class="settings-panel">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label data-i18n="settings.mqtt.label">MQTT</label> <label data-i18n="settings.mqtt.label">MQTT</label>
@@ -169,51 +216,6 @@
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button> <button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
</div> </div>
<!-- Server Logs section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.logs.label">Server Logs</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.logs.hint">Stream live server log output. Use the filter to show only relevant log levels.</small>
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
<button id="log-viewer-connect-btn" class="btn btn-secondary" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
<button class="btn btn-secondary" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
<select id="log-viewer-filter" onchange="applyLogFilter()" style="flex:1; font-size:0.85rem;">
<option value="all" data-i18n="settings.logs.filter.all">All</option>
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
</select>
</div>
<pre id="log-viewer-output" class="log-viewer-output"></pre>
</div>
<!-- Log Level section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.log_level.label">Log Level</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.log_level.hint">Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.</small>
<select id="settings-log-level">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<!-- Restart section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restart_server">Restart Server</label>
</div>
<button class="btn btn-secondary" onclick="restartServer()" style="width:100%" data-i18n="settings.restart_server">Restart Server</button>
</div> </div>
<div id="settings-error" class="error-message" style="display:none;"></div> <div id="settings-error" class="error-message" style="display:none;"></div>
@@ -223,3 +225,20 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Log Viewer Overlay (full-screen, independent of settings modal) -->
<div id="log-overlay" class="log-overlay" style="display:none;">
<button class="log-overlay-close" onclick="closeLogOverlay()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
<div class="log-overlay-toolbar">
<h3 data-i18n="settings.logs.label">Server Logs</h3>
<select id="log-viewer-filter" onchange="applyLogFilter()">
<option value="all" data-i18n="settings.logs.filter.all">All levels</option>
<option value="INFO" data-i18n="settings.logs.filter.info">Info+</option>
<option value="WARNING" data-i18n="settings.logs.filter.warning">Warning+</option>
<option value="ERROR" data-i18n="settings.logs.filter.error">Error only</option>
</select>
<button id="log-viewer-connect-btn" class="btn btn-secondary btn-sm" onclick="connectLogViewer()" data-i18n="settings.logs.connect">Connect</button>
<button class="btn btn-secondary btn-sm" onclick="clearLogViewer()" data-i18n="settings.logs.clear">Clear</button>
</div>
<pre id="log-viewer-output" class="log-viewer-output"></pre>
</div>

View File

@@ -51,7 +51,7 @@
</div> </div>
<!-- LED count & FPS controls --> <!-- LED count & FPS controls -->
<div class="css-test-led-control"> <div id="css-test-led-fps-group" class="css-test-led-control">
<span id="css-test-led-group"> <span id="css-test-led-group">
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label> <label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input"> <input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input">
@@ -62,6 +62,17 @@
<button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">&#x2713;</button> <button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">&#x2713;</button>
</div> </div>
<!-- FPS chart (for api_input sources) — matches target card sparkline -->
<div id="css-test-fps-chart-group" class="target-fps-row" style="display:none">
<div class="target-fps-sparkline">
<canvas id="css-test-fps-chart"></canvas>
</div>
<div class="target-fps-label">
<span id="css-test-fps-value" class="metric-value">0</span>
<span class="target-fps-avg" id="css-test-fps-avg"></span>
</div>
</div>
<div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div> <div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
</div> </div>
</div> </div>