diff --git a/custom_components/wled_screen_controller/__init__.py b/custom_components/wled_screen_controller/__init__.py index f0bfe3d..65a651e 100644 --- a/custom_components/wled_screen_controller/__init__.py +++ b/custom_components/wled_screen_controller/__init__.py @@ -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 diff --git a/custom_components/wled_screen_controller/coordinator.py b/custom_components/wled_screen_controller/coordinator.py index d7e77a6..65df416 100644 --- a/custom_components/wled_screen_controller/coordinator.py +++ b/custom_components/wled_screen_controller/coordinator.py @@ -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( diff --git a/custom_components/wled_screen_controller/light.py b/custom_components/wled_screen_controller/light.py new file mode 100644 index 0000000..d370f66 --- /dev/null +++ b/custom_components/wled_screen_controller/light.py @@ -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] diff --git a/custom_components/wled_screen_controller/services.yaml b/custom_components/wled_screen_controller/services.yaml new file mode 100644 index 0000000..8499fdd --- /dev/null +++ b/custom_components/wled_screen_controller/services.yaml @@ -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: diff --git a/custom_components/wled_screen_controller/strings.json b/custom_components/wled_screen_controller/strings.json index 1e7268e..b9c983a 100644 --- a/custom_components/wled_screen_controller/strings.json +++ b/custom_components/wled_screen_controller/strings.json @@ -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." + } + } + } } } diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index eba060e..e73f724 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -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() diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 73e9ce6..4c61d99 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -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): diff --git a/server/src/wled_controller/core/processing/api_input_stream.py b/server/src/wled_controller/core/processing/api_input_stream.py index 37a278c..1b43c6e 100644 --- a/server/src/wled_controller/core/processing/api_input_stream.py +++ b/server/src/wled_controller/core/processing/api_input_stream.py @@ -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: diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index bff7d9f..c502762 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -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; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 330e944..217300c 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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(); diff --git a/server/src/wled_controller/static/js/core/entity-events.js b/server/src/wled_controller/static/js/core/entity-events.js index 15d4fe7..ec741e5 100644 --- a/server/src/wled_controller/static/js/core/entity-events.js +++ b/server/src/wled_controller/static/js/core/entity-events.js @@ -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 }, })); diff --git a/server/src/wled_controller/static/js/features/audio-sources.js b/server/src/wled_controller/static/js/features/audio-sources.js index f6423ae..0069d1a 100644 --- a/server/src/wled_controller/static/js/features/audio-sources.js +++ b/server/src/wled_controller/static/js/features/audio-sources.js @@ -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'); diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index e93b20e..2bcb481 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -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; diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index a0b3d7d..5f2fbb5 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -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 ? `` : ''; - const testPreviewBtn = !isApiInput - ? `` - : ''; + const testPreviewBtn = ``; 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 => + `` + ).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 => - `` - ).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 = ''; } diff --git a/server/src/wled_controller/static/js/features/scene-presets.js b/server/src/wled_controller/static/js/features/scene-presets.js index 734c643..ef54796 100644 --- a/server/src/wled_controller/static/js/features/scene-presets.js +++ b/server/src/wled_controller/static/js/features/scene-presets.js @@ -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'); diff --git a/server/src/wled_controller/static/js/features/settings.js b/server/src/wled_controller/static/js/features/settings.js index 501f51e..2b05045 100644 --- a/server/src/wled_controller/static/js/features/settings.js +++ b/server/src/wled_controller/static/js/features/settings.js @@ -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: '*', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') }, + { value: 'INFO', icon: 'I', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') }, + { value: 'WARNING', icon: 'W', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') }, + { value: 'ERROR', icon: 'E', 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: 'D', label: 'DEBUG', desc: t('settings.log_level.desc.debug') }, - { value: 'INFO', icon: 'I', label: 'INFO', desc: t('settings.log_level.desc.info') }, - { value: 'WARNING', icon: 'W', label: 'WARNING', desc: t('settings.log_level.desc.warning') }, - { value: 'ERROR', icon: 'E', label: 'ERROR', desc: t('settings.log_level.desc.error') }, - { value: 'CRITICAL', icon: '!', label: 'CRITICAL', desc: t('settings.log_level.desc.critical') }, -]; - -const _LOG_FILTER_ITEMS = [ - { value: 'all', icon: '*', label: t('settings.logs.filter.all'), desc: t('settings.logs.filter.all_desc') }, - { value: 'INFO', icon: 'I', label: t('settings.logs.filter.info'), desc: t('settings.logs.filter.info_desc') }, - { value: 'WARNING', icon: 'W', label: t('settings.logs.filter.warning'), desc: t('settings.logs.filter.warning_desc') }, - { value: 'ERROR', icon: 'E', 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: 'D', label: 'DEBUG', desc: t('settings.log_level.desc.debug') }, + { value: 'INFO', icon: 'I', label: 'INFO', desc: t('settings.log_level.desc.info') }, + { value: 'WARNING', icon: 'W', label: 'WARNING', desc: t('settings.log_level.desc.warning') }, + { value: 'ERROR', icon: 'E', label: 'ERROR', desc: t('settings.log_level.desc.error') }, + { value: 'CRITICAL', icon: '!', 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(); } diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index f34867a..1c9bc06 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -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); diff --git a/server/src/wled_controller/static/js/features/sync-clocks.js b/server/src/wled_controller/static/js/features/sync-clocks.js index 325704d..d3dc782 100644 --- a/server/src/wled_controller/static/js/features/sync-clocks.js +++ b/server/src/wled_controller/static/js/features/sync-clocks.js @@ -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; diff --git a/server/src/wled_controller/static/js/features/value-sources.js b/server/src/wled_controller/static/js/features/value-sources.js index 13992c8..b15894d 100644 --- a/server/src/wled_controller/static/js/features/value-sources.js +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -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'); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index a31a1c7..008151a 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 553933f..60174bd 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Предпросмотр", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index d645eed..063f608 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "实时预览", diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index a0214db..3d89361 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -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 diff --git a/server/src/wled_controller/templates/modals/settings.html b/server/src/wled_controller/templates/modals/settings.html index 623ad89..53f2090 100644 --- a/server/src/wled_controller/templates/modals/settings.html +++ b/server/src/wled_controller/templates/modals/settings.html @@ -1,219 +1,221 @@