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
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -29,6 +31,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SWITCH,
Platform.SENSOR,
Platform.NUMBER,
@@ -148,6 +151,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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)
return True

View File

@@ -336,6 +336,38 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
_LOGGER.warning("Failed to fetch scene presets: %s", err)
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:
"""Activate a scene preset."""
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}"
}
},
"light": {
"api_input_light": {
"name": "Light"
}
},
"switch": {
"processing": {
"name": "Processing"
@@ -66,5 +71,21 @@
"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.
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:
source = store.get_source(source_id)
@@ -420,20 +421,32 @@ async def push_colors(
if not isinstance(source, ApiInputColorStripSource):
raise HTTPException(status_code=400, detail="Source is not an api_input type")
colors_array = np.array(body.colors, dtype=np.uint8)
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")
streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id)
for stream in streams:
if hasattr(stream, "push_colors"):
stream.push_colors(colors_array)
return {
"status": "ok",
"streams_updated": len(streams),
"leds_received": len(body.colors),
}
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)
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")
for stream in streams:
if hasattr(stream, "push_colors"):
stream.push_colors(colors_array)
return {
"status": "ok",
"streams_updated": len(streams),
"leds_received": len(body.colors),
}
@router.post("/api/v1/color-strip-sources/{source_id}/notify", tags=["Color Strip Sources"])
@@ -708,19 +721,42 @@ async def css_api_input_ws(
break
if "text" in message:
# JSON frame: {"colors": [[R,G,B], ...]}
# JSON frame: {"colors": [[R,G,B], ...]} or {"segments": [...]}
import json
try:
data = json.loads(message["text"])
raw_colors = data.get("colors", [])
colors_array = np.array(raw_colors, dtype=np.uint8)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue
except (json.JSONDecodeError, ValueError, TypeError) as e:
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)
if colors_array.ndim != 2 or colors_array.shape[1] != 3:
await websocket.send_json({"error": "Colors must be [[R,G,B], ...]"})
continue
except (ValueError, TypeError) as e:
await websocket.send_json({"error": str(e)})
continue
else:
await websocket.send_json({"error": "JSON frame must contain 'colors' or 'segments'"})
continue
elif "bytes" in message:
# Binary frame: raw RGBRGB... bytes (3 bytes per LED)
raw_bytes = message["bytes"]
@@ -732,7 +768,7 @@ async def css_api_input_ws(
else:
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)
for stream in streams:
if hasattr(stream, "push_colors"):
@@ -799,6 +835,10 @@ async def test_color_strip_ws(
try:
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
is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource))
is_composite = isinstance(source, CompositeColorStripSource)
@@ -875,9 +915,18 @@ async def test_color_strip_ws(
elif composite_colors is not None:
await websocket.send_bytes(composite_colors.tobytes())
else:
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
# 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:
colors = stream.get_latest_colors()
if colors is not None:
await websocket.send_bytes(colors.tobytes())
# Periodically send auxiliary data (frame preview, brightness)
now = _time.monotonic()

View File

@@ -3,7 +3,7 @@
from datetime import datetime
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
@@ -237,10 +237,52 @@ class ColorStripSourceListResponse(BaseModel):
count: int = Field(description="Number of sources")
class ColorPushRequest(BaseModel):
"""Request to push raw LED colors to an api_input source."""
class SegmentPayload(BaseModel):
"""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):

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
received within `timeout` seconds, LEDs revert to `fallback_color`.
Thread-safe: push_colors() can be called from any thread (REST handler,
WebSocket handler) while get_latest_colors() is called from the target
processor thread.
Thread-safe: push_colors() / push_segments() can be called from any thread
(REST handler, WebSocket handler) while get_latest_colors() is called from
the target processor thread.
"""
import threading
@@ -20,13 +20,16 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
_DEFAULT_LED_COUNT = 150
class ApiInputColorStripStream(ColorStripStream):
"""Color strip stream backed by externally-pushed LED color data.
Holds a thread-safe np.ndarray buffer. External clients push colors via
push_colors(). A background thread checks for timeout and reverts to
fallback_color when no data arrives within the configured timeout window.
push_colors() or push_segments(). A background thread checks for timeout
and reverts to fallback_color when no data arrives within the configured
timeout window.
"""
def __init__(self, source):
@@ -43,14 +46,14 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color
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._auto_size = not source.led_count
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
self._led_count = _DEFAULT_LED_COUNT
# Build initial fallback buffer
self._fallback_array = self._build_fallback(self._led_count)
self._colors = self._fallback_array.copy()
self._last_push_time: float = 0.0
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:
"""Build a (led_count, 3) uint8 array filled with fallback_color."""
@@ -59,40 +62,124 @@ class ApiInputColorStripStream(ColorStripStream):
(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:
"""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:
colors: np.ndarray shape (N, 3) uint8
"""
with self._lock:
n = len(colors)
# Auto-grow if incoming data is larger
if n > self._led_count:
self._ensure_capacity(n)
if n == self._led_count:
self._colors = colors.astype(np.uint8)
elif n > self._led_count:
self._colors = colors[:self._led_count].astype(np.uint8)
else:
elif n < self._led_count:
# Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
padded[:n] = colors[:n]
self._colors = padded
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
def configure(self, device_led_count: int) -> None:
"""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:
self._led_count = device_led_count
self._fallback_array = self._build_fallback(device_led_count)
self._colors = self._fallback_array.copy()
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
def target_fps(self) -> int:
@@ -131,6 +218,11 @@ class ApiInputColorStripStream(ColorStripStream):
with self._lock:
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:
"""Hot-update fallback_color and timeout from updated source config."""
from wled_controller.storage.color_strip_source import ApiInputColorStripSource
@@ -138,19 +230,10 @@ class ApiInputColorStripStream(ColorStripStream):
fallback = source.fallback_color
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)
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:
self._fallback_array = self._build_fallback(self._led_count)
if self._timed_out:
self._colors = self._fallback_array.copy()
logger.info("ApiInputColorStripStream params updated in-place")
def _timeout_loop(self) -> None:

View File

@@ -269,6 +269,8 @@
font-size: 0.9em;
}
/* FPS chart for api_input test preview — reuses .target-fps-row from cards.css */
/* Composite layers preview */
.css-test-layers {
display: flex;
@@ -346,7 +348,107 @@
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 {
background: #0d0d0d;

View File

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

View File

@@ -30,11 +30,51 @@ const ENTITY_CACHE_MAP = {
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) {
const cache = ENTITY_CACHE_MAP[entityType];
if (cache) {
cache.fetch({ force: true });
}
if (!cache) return;
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', {
detail: { entity_type: entityType },
}));

View File

@@ -10,7 +10,7 @@
* 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 { t } from '../core/i18n.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');
audioSourceModal.forceClose();
audioSourcesCache.invalidate();
await loadPictureSources();
} catch (e) {
errorEl.textContent = e.message;
@@ -205,6 +206,7 @@ export async function deleteAudioSource(sourceId) {
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('audio_source.deleted'), 'success');
audioSourcesCache.invalidate();
await loadPictureSources();
} catch (e) {
showToast(e.message, 'error');

View File

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

View File

@@ -3,6 +3,7 @@
*/
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 { t } from '../core/i18n.js';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
@@ -198,8 +199,8 @@ export function onCSSTypeChange() {
}
_syncAnimationSpeedState();
// LED count — only shown for picture, picture_advanced, api_input
const hasLedCount = ['picture', 'picture_advanced', 'api_input'];
// LED count — only shown for picture, picture_advanced
const hasLedCount = ['picture', 'picture_advanced'];
document.getElementById('css-editor-led-count-group').style.display =
hasLedCount.includes(type) ? '' : 'none';
@@ -1656,9 +1657,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const notifHistoryBtn = isNotification
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
: '';
const testPreviewBtn = !isApiInput
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
: '';
const testPreviewBtn = `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`;
return wrapCard({
dataAttr: 'data-css-id',
@@ -2259,6 +2258,7 @@ export async function saveCSSEditor() {
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
colorStripSourcesCache.invalidate();
cssEditorModal.forceClose();
if (window.loadPictureSources) window.loadPictureSources();
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
@@ -2335,6 +2335,7 @@ export async function deleteColorStrip(cssId) {
if (response.ok) {
showToast(t('color_strip.deleted'), 'success');
colorStripSourcesCache.invalidate();
if (window.loadPictureSources) window.loadPictureSources();
if (window.loadTargetsTab) await window.loadTargetsTab();
} else {
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 _cssTestCSPTMode = false; // true when testing a CSPT template
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;
function _getCssTestLedCount() {
@@ -2488,12 +2494,32 @@ function _getCssTestFps() {
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) {
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
// Hide CSPT input selector
const csptGroup = document.getElementById('css-test-cspt-input-group');
if (csptGroup) csptGroup.style.display = 'none';
// Detect api_input type
const sources = colorStripSourcesCache.data || [];
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);
}
@@ -2503,25 +2529,9 @@ export async function testCSPT(templateId) {
// Populate input source selector
await colorStripSourcesCache.fetch();
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}">${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 = '';
_populateCssTestSourceSelector(null);
const sel = document.getElementById('css-test-cspt-input-select');
const inputId = sel.value;
if (!inputId) {
showToast(t('color_strip.processed.error.no_input'), 'error');
@@ -2550,23 +2560,42 @@ function _openTestModal(sourceId) {
document.getElementById('css-test-rect-view').style.display = 'none';
document.getElementById('css-test-layers-view').style.display = 'none';
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');
if (layersContainer) layersContainer.innerHTML = '';
document.getElementById('css-test-status').style.display = '';
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
// Restore LED count + FPS + Enter key handlers
const ledCount = _getCssTestLedCount();
const ledInput = document.getElementById('css-test-led-input');
ledInput.value = ledCount;
ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
// Reset FPS tracking
_cssTestFpsHistory = [];
const fpsVal = _getCssTestFps();
const fpsInput = document.getElementById('css-test-fps-input');
fpsInput.value = fpsVal;
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
// 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
const ledCount = _getCssTestLedCount();
const ledInput = document.getElementById('css-test-led-input');
ledInput.value = ledCount;
ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
_cssTestConnect(sourceId, ledCount, fpsVal);
const fpsVal = _getCssTestFps();
const fpsInput = document.getElementById('css-test-fps-input');
fpsInput.value = fpsVal;
fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } };
_cssTestConnect(sourceId, ledCount, fpsVal);
}
}
function _cssTestConnect(sourceId, ledCount, fps) {
@@ -2714,6 +2743,11 @@ function _cssTestConnect(sourceId, ledCount, fps) {
// Standard format: raw RGB
_cssTestLatestRgb = raw;
}
// Track FPS for api_input sources
if (_cssTestIsApiInput) {
_cssTestFpsTimestamps.push(performance.now());
}
}
};
@@ -2805,12 +2839,14 @@ export function applyCssTestSettings() {
_cssTestMeta = null;
_cssTestLayerData = null;
// In CSPT mode, read selected input source
if (_cssTestCSPTMode) {
const inputSel = document.getElementById('css-test-cspt-input-select');
if (inputSel && inputSel.value) {
_cssTestSourceId = inputSel.value;
}
// Read selected input source from selector (both CSS and CSPT modes)
const inputSel = document.getElementById('css-test-cspt-input-select');
if (inputSel && 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)
@@ -3162,6 +3198,60 @@ export function fireCssTestNotificationLayer(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() {
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
@@ -3171,6 +3261,10 @@ export function closeTestCssSourceModal() {
_cssTestIsComposite = false;
_cssTestLayerData = null;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
_cssTestStopFpsSampling();
_cssTestFpsTimestamps = [];
_cssTestFpsActualHistory = [];
// Revoke blob URL for frame preview
const screen = document.getElementById('css-test-rect-screen');
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();
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab();
} catch (error) {
if (error.isAuth) return;
@@ -348,6 +349,7 @@ export async function recaptureScenePreset(presetId) {
});
if (resp.ok) {
showToast(t('scenes.recaptured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab();
} else {
showToast(t('scenes.error.recapture_failed'), 'error');
@@ -420,6 +422,7 @@ export async function deleteScenePreset(presetId) {
});
if (resp.ok) {
showToast(t('scenes.deleted'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab();
} else {
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';
@@ -10,6 +10,17 @@ import { t } from '../core/i18n.js';
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.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 ────────────────────────────────────────────
/** @type {WebSocket|null} */
@@ -114,9 +125,6 @@ export function clearLogViewer() {
/** Re-render the log output according to the current filter selection. */
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');
if (!output) return;
const filter = _filterLevel();
@@ -128,50 +136,84 @@ export function applyLogFilter() {
}
}
// ─── Log Overlay (full-screen) ──────────────────────────────
let _logFilterIconSelect = null;
/** Build filter items lazily so t() has locale data loaded. */
function _getLogFilterItems() {
return [
{ 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: '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') },
];
}
export function openLogOverlay() {
const overlay = document.getElementById('log-overlay');
if (overlay) {
overlay.style.display = 'flex';
// Initialize log filter icon select (once)
if (!_logFilterIconSelect) {
const filterSel = document.getElementById('log-viewer-filter');
if (filterSel) {
_logFilterIconSelect = new IconSelect({
target: filterSel,
items: _getLogFilterItems(),
columns: 2,
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 _logFilterIconSelect = null;
let _logLevelIconSelect = null;
const _LOG_LEVEL_ITEMS = [
{ 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') },
];
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: '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: 'ERROR', icon: '<span style="color:#ef5350;font-weight:700">E</span>', label: t('settings.logs.filter.error'), desc: t('settings.logs.filter.error_desc') },
];
/** 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 filter icon select
if (!_logFilterIconSelect) {
const filterSel = document.getElementById('log-viewer-filter');
if (filterSel) {
_logFilterIconSelect = new IconSelect({
target: filterSel,
items: _LOG_FILTER_ITEMS,
columns: 2,
onChange: () => applyLogFilter(),
});
}
}
// Initialize log level icon select
if (!_logLevelIconSelect) {
const levelSel = document.getElementById('settings-log-level');
if (levelSel) {
_logLevelIconSelect = new IconSelect({
target: levelSel,
items: _LOG_LEVEL_ITEMS,
items: _getLogLevelItems(),
columns: 3,
onChange: () => setLogLevel(),
});
@@ -186,7 +228,6 @@ export function openSettingsModal() {
}
export function closeSettingsModal() {
disconnectLogViewer();
settingsModal.forceClose();
}

View File

@@ -710,6 +710,7 @@ export async function saveTemplate() {
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
templateModal.forceClose();
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (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');
}
showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (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');
audioTemplateModal.forceClose();
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (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');
}
showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate();
await loadAudioTemplates();
} catch (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');
streamModal.forceClose();
streamsCache.invalidate();
await loadPictureSources();
} catch (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');
}
showToast(t('streams.deleted'), 'success');
streamsCache.invalidate();
await loadPictureSources();
} catch (error) {
console.error('Error deleting stream:', error);
@@ -2675,6 +2681,7 @@ export async function savePPTemplate() {
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
ppTemplateModal.forceClose();
ppTemplatesCache.invalidate();
await loadPPTemplates();
} catch (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');
}
showToast(t('postprocessing.deleted'), 'success');
ppTemplatesCache.invalidate();
await loadPPTemplates();
} catch (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');
csptModal.forceClose();
csptCache.invalidate();
await loadCSPTemplates();
} catch (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');
}
showToast(t('css_processing.deleted'), 'success');
csptCache.invalidate();
await loadCSPTemplates();
} catch (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');
syncClockModal.forceClose();
syncClocksCache.invalidate();
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;
@@ -143,6 +144,7 @@ export async function deleteSyncClock(clockId) {
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('sync_clock.deleted'), 'success');
syncClocksCache.invalidate();
await loadPictureSources();
} catch (e) {
if (e.isAuth) return;

View File

@@ -10,7 +10,7 @@
* 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 { t } from '../core/i18n.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');
valueSourceModal.forceClose();
valueSourcesCache.invalidate();
await loadPictureSources();
} catch (e) {
errorEl.textContent = e.message;
@@ -536,6 +537,7 @@ export async function deleteValueSource(sourceId) {
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('value_source.deleted'), 'success');
valueSourcesCache.invalidate();
await loadPictureSources();
} catch (e) {
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.add": "Click here to add a new LED device",
"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.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated",
@@ -1076,6 +1080,7 @@
"color_strip.test.error": "Failed to connect to preview stream",
"color_strip.test.led_count": "LEDs:",
"color_strip.test.fps": "FPS:",
"color_strip.test.receive_fps": "Receive FPS",
"color_strip.test.apply": "Apply",
"color_strip.test.composite": "Composite",
"color_strip.preview.title": "Live Preview",

View File

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

View File

@@ -312,6 +312,10 @@
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
"device.tip.add": "点击此处添加新的 LED 设备",
"settings.title": "设置",
"settings.tab.general": "常规",
"settings.tab.backup": "备份",
"settings.tab.mqtt": "MQTT",
"settings.logs.open_viewer": "打开日志查看器",
"settings.general.title": "常规设置",
"settings.capture.title": "采集设置",
"settings.capture.saved": "采集设置已更新",
@@ -1076,6 +1080,7 @@
"color_strip.test.error": "无法连接到预览流",
"color_strip.test.led_count": "LED数量:",
"color_strip.test.fps": "FPS:",
"color_strip.test.receive_fps": "接收帧率",
"color_strip.test.apply": "应用",
"color_strip.test.composite": "合成",
"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
buffers the latest frame and serves it to targets. When no data has been
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]
timeout: float = 5.0 # seconds before reverting to fallback
def to_dict(self) -> dict:
d = super().to_dict()
d["led_count"] = self.led_count
d["fallback_color"] = list(self.fallback_color)
d["timeout"] = self.timeout
return d
@@ -810,14 +808,14 @@ class ApiInputColorStripSource(ColorStripSource):
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
led_count=0, fallback_color=None, timeout=None,
fallback_color=None, timeout=None,
**_kwargs):
fb = _validate_rgb(fallback_color, [0, 0, 0])
return cls(
id=id, name=name, source_type="api_input",
created_at=created_at, updated_at=updated_at,
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,
)
@@ -827,8 +825,6 @@ class ApiInputColorStripSource(ColorStripSource):
self.fallback_color = fallback_color
if kwargs.get("timeout") is not None:
self.timeout = float(kwargs["timeout"])
if kwargs.get("led_count") is not None:
self.led_count = kwargs["led_count"]
@dataclass

View File

@@ -1,219 +1,221 @@
<!-- Settings Modal -->
<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">
<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>
</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">
<!-- API Keys section (read-only) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.api_keys.label">API Keys</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ═══ General tab ═══ -->
<div id="settings-panel-general" class="settings-panel active">
<!-- API Keys section (read-only) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.api_keys.label">API Keys</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.api_keys.hint">API keys are defined in the server config file (config.yaml). Restart the server after editing the file to apply changes.</small>
<div id="settings-api-keys-list" style="font-size:0.85rem;"></div>
</div>
<!-- Backup section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.backup.label">Backup Configuration</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</small>
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
</div>
<!-- Restore section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restore.label">Restore Configuration</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
<input type="file" id="settings-restore-input" accept=".json" style="display:none" onchange="handleRestoreFileSelected(this)">
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
</div>
<!-- Partial Export/Import section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.partial.label">Partial Export / Import</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.partial.hint">Export or import a single entity type. Import replaces or merges existing data and restarts the server.</small>
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
<select id="settings-partial-store" style="flex:1">
<option value="devices" data-i18n="settings.partial.store.devices">Devices</option>
<option value="output_targets" data-i18n="settings.partial.store.output_targets">LED Targets</option>
<option value="color_strip_sources" data-i18n="settings.partial.store.color_strip_sources">Color Strips</option>
<option value="picture_sources" data-i18n="settings.partial.store.picture_sources">Picture Sources</option>
<option value="audio_sources" data-i18n="settings.partial.store.audio_sources">Audio Sources</option>
<option value="audio_templates" data-i18n="settings.partial.store.audio_templates">Audio Templates</option>
<option value="capture_templates" data-i18n="settings.partial.store.capture_templates">Capture Templates</option>
<option value="postprocessing_templates" data-i18n="settings.partial.store.postprocessing_templates">Post-processing Templates</option>
<option value="color_strip_processing_templates" data-i18n="settings.partial.store.color_strip_processing_templates">CSS Processing Templates</option>
<option value="pattern_templates" data-i18n="settings.partial.store.pattern_templates">Pattern Templates</option>
<option value="value_sources" data-i18n="settings.partial.store.value_sources">Value Sources</option>
<option value="sync_clocks" data-i18n="settings.partial.store.sync_clocks">Sync Clocks</option>
<option value="automations" data-i18n="settings.partial.store.automations">Automations</option>
<option value="scene_presets" data-i18n="settings.partial.store.scene_presets">Scene Presets</option>
<!-- 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>
<button class="btn btn-secondary" onclick="downloadPartialExport()" data-i18n="settings.partial.export_button">Export</button>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
<input type="checkbox" id="settings-partial-merge">
<label for="settings-partial-merge" style="margin:0;font-size:0.85rem;" data-i18n="settings.partial.merge_label">Merge (add/overwrite, keep existing)</label>
<!-- 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>
<input type="file" id="settings-partial-import-input" accept=".json" style="display:none" onchange="handlePartialImportFileSelected(this)">
<button class="btn btn-secondary" onclick="document.getElementById('settings-partial-import-input').click()" style="width:100%" data-i18n="settings.partial.import_button">Import from File</button>
<!-- 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>
<!-- Auto-Backup section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.auto_backup.label">Auto-Backup</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.auto_backup.hint">Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.</small>
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
<input type="checkbox" id="auto-backup-enabled">
<label for="auto-backup-enabled" style="margin:0" data-i18n="settings.auto_backup.enable">Enable auto-backup</label>
<!-- ═══ Backup tab ═══ -->
<div id="settings-panel-backup" class="settings-panel">
<!-- Backup section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.backup.label">Backup Configuration</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</small>
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="auto-backup-interval" style="font-size:0.85rem" data-i18n="settings.auto_backup.interval_label">Interval</label>
<select id="auto-backup-interval" style="width:100%">
<option value="1">1h</option>
<option value="6">6h</option>
<option value="12">12h</option>
<option value="24">24h</option>
<option value="48">48h</option>
<option value="168">7d</option>
<!-- Restore section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.restore.label">Restore Configuration</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.restore.hint">Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.</small>
<input type="file" id="settings-restore-input" accept=".json" style="display:none" onchange="handleRestoreFileSelected(this)">
<button class="btn btn-danger" onclick="document.getElementById('settings-restore-input').click()" style="width:100%" data-i18n="settings.restore.button">Restore from Backup</button>
</div>
<!-- Partial Export/Import section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.partial.label">Partial Export / Import</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.partial.hint">Export or import a single entity type. Import replaces or merges existing data and restarts the server.</small>
<div style="display:flex;gap:0.5rem;margin-bottom:0.5rem;">
<select id="settings-partial-store" style="flex:1">
<option value="devices" data-i18n="settings.partial.store.devices">Devices</option>
<option value="output_targets" data-i18n="settings.partial.store.output_targets">LED Targets</option>
<option value="color_strip_sources" data-i18n="settings.partial.store.color_strip_sources">Color Strips</option>
<option value="picture_sources" data-i18n="settings.partial.store.picture_sources">Picture Sources</option>
<option value="audio_sources" data-i18n="settings.partial.store.audio_sources">Audio Sources</option>
<option value="audio_templates" data-i18n="settings.partial.store.audio_templates">Audio Templates</option>
<option value="capture_templates" data-i18n="settings.partial.store.capture_templates">Capture Templates</option>
<option value="postprocessing_templates" data-i18n="settings.partial.store.postprocessing_templates">Post-processing Templates</option>
<option value="color_strip_processing_templates" data-i18n="settings.partial.store.color_strip_processing_templates">CSS Processing Templates</option>
<option value="pattern_templates" data-i18n="settings.partial.store.pattern_templates">Pattern Templates</option>
<option value="value_sources" data-i18n="settings.partial.store.value_sources">Value Sources</option>
<option value="sync_clocks" data-i18n="settings.partial.store.sync_clocks">Sync Clocks</option>
<option value="automations" data-i18n="settings.partial.store.automations">Automations</option>
<option value="scene_presets" data-i18n="settings.partial.store.scene_presets">Scene Presets</option>
</select>
<button class="btn btn-secondary" onclick="downloadPartialExport()" data-i18n="settings.partial.export_button">Export</button>
</div>
<div style="flex:1">
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label>
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
<input type="checkbox" id="settings-partial-merge">
<label for="settings-partial-merge" style="margin:0;font-size:0.85rem;" data-i18n="settings.partial.merge_label">Merge (add/overwrite, keep existing)</label>
</div>
<input type="file" id="settings-partial-import-input" accept=".json" style="display:none" onchange="handlePartialImportFileSelected(this)">
<button class="btn btn-secondary" onclick="document.getElementById('settings-partial-import-input').click()" style="width:100%" data-i18n="settings.partial.import_button">Import from File</button>
</div>
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="width:100%" data-i18n="settings.auto_backup.save">Save Settings</button>
<!-- Auto-Backup section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.auto_backup.label">Auto-Backup</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.auto_backup.hint">Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.</small>
<div id="auto-backup-status" style="font-size:0.85rem; color:var(--text-muted); margin-top:0.5rem;"></div>
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
<input type="checkbox" id="auto-backup-enabled">
<label for="auto-backup-enabled" style="margin:0" data-i18n="settings.auto_backup.enable">Enable auto-backup</label>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="auto-backup-interval" style="font-size:0.85rem" data-i18n="settings.auto_backup.interval_label">Interval</label>
<select id="auto-backup-interval" style="width:100%">
<option value="1">1h</option>
<option value="6">6h</option>
<option value="12">12h</option>
<option value="24">24h</option>
<option value="48">48h</option>
<option value="168">7d</option>
</select>
</div>
<div style="flex:1">
<label for="auto-backup-max" style="font-size:0.85rem" data-i18n="settings.auto_backup.max_label">Max backups</label>
<input type="number" id="auto-backup-max" min="1" max="100" value="10" style="width:100%">
</div>
</div>
<button class="btn btn-primary" onclick="saveAutoBackupSettings()" style="width:100%" data-i18n="settings.auto_backup.save">Save Settings</button>
<div id="auto-backup-status" style="font-size:0.85rem; color:var(--text-muted); margin-top:0.5rem;"></div>
</div>
<!-- Saved Backups section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.saved_backups.label">Saved Backups</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<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>
</div>
<!-- Saved Backups section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.saved_backups.label">Saved Backups</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<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>
<!-- MQTT section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.mqtt.label">MQTT</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
<input type="checkbox" id="mqtt-enabled">
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
<!-- ═══ MQTT tab ═══ -->
<div id="settings-panel-mqtt" class="settings-panel">
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.mqtt.label">MQTT</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<div style="width:90px">
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
<input type="checkbox" id="mqtt-enabled">
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
</div>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
</div>
<div style="width:90px">
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
</div>
</div>
<div style="flex:1">
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
</div>
</div>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
<div style="flex:1">
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
<div style="flex:1">
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
</div>
</div>
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
</div>
<button class="btn btn-primary" onclick="saveMqttSettings()" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
</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 id="settings-error" class="error-message" style="display:none;"></div>
@@ -223,3 +225,20 @@
</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>
<!-- 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">
<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">
@@ -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>
</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>
</div>