From 797b80697249b03b8489df223d527b5c57743549 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 1 May 2026 03:02:13 +0300 Subject: [PATCH] feat: LED hot-path perf, tutorials expansion, modal markup polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance (LED hot path, allocation-free per-frame): - Adalight: dedicated single-worker tx executor (avoids asyncio.to_thread overhead), pre-allocated wire buffer + uint8 scratch, header struct precomputed. New tests cover header format, buffer reuse, non-contiguous input, and brightness scaling. - DDP: pre-built struct.Struct for the 10-byte header, allocation-free send buffer + memoryview emit path. New tests cover RGB/RGBW packets, sequence/PUSH semantics, and multi-packet fragmentation. - Calibration: precomputed Phase 3 skip-LED resampling (floor/ceil indices, fractional weights, take/blend scratch buffers) — per-frame work is now np.take + in-place blend, no allocations. - WLED target processor: matching hot-path tightening. Tutorials: - Sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks so a tour can open/close the dashboard customize panel and resolve targets behind sub-tabs. - New steps for integrations tab, dashboard customize panel (presets, global, sections, perf cells), targets, scenes, sync-clocks. - en/ru/zh locales updated with the new tour strings. Dashboard layout: - Structural deep-equal so the "modified" indicator reflects truth after a user edits then reverts, instead of a stale flag. UI polish: - Mod-card / modal markup pass across ~33 modal templates and the tutorial overlay partial. - appearance.css, modal.css, tutorials.css refresh. Tooling: - Add .mcp.json with code-review-graph MCP server config so the graph tools are available to the team out of the box. --- .mcp.json | 12 + .../src/ledgrab/core/capture/calibration.py | 235 ++++++++---- .../ledgrab/core/devices/adalight_client.py | 123 ++++++- server/src/ledgrab/core/devices/ddp_client.py | 200 +++++----- .../core/processing/wled_target_processor.py | 212 ++++++----- server/src/ledgrab/static/css/appearance.css | 32 +- server/src/ledgrab/static/css/modal.css | 43 ++- server/src/ledgrab/static/css/tutorials.css | 343 ++++++++++++++++++ server/src/ledgrab/static/js/app.ts | 2 + server/src/ledgrab/static/js/core/state.ts | 25 +- .../js/features/advanced-calibration.ts | 3 + .../ledgrab/static/js/features/calibration.ts | 6 + .../static/js/features/dashboard-layout.ts | 113 +++++- .../static/js/features/integrations.ts | 3 +- .../ledgrab/static/js/features/tutorials.ts | 274 ++++++++++++-- server/src/ledgrab/static/js/global.d.ts | 1 + server/src/ledgrab/static/locales/en.json | 74 +++- server/src/ledgrab/static/locales/ru.json | 74 +++- server/src/ledgrab/static/locales/zh.json | 74 +++- .../ledgrab/templates/modals/add-device.html | 36 +- .../modals/advanced-calibration.html | 239 ++++++------ .../src/ledgrab/templates/modals/api-key.html | 48 ++- .../templates/modals/asset-editor.html | 42 ++- .../templates/modals/asset-upload.html | 100 +++-- .../modals/audio-processing-template.html | 66 ++-- .../templates/modals/audio-source-editor.html | 146 ++++---- .../templates/modals/audio-template.html | 68 ++-- .../templates/modals/automation-editor.html | 174 +++++---- .../ledgrab/templates/modals/calibration.html | 298 ++++++++------- .../templates/modals/capture-template.html | 68 ++-- .../ledgrab/templates/modals/cspt-modal.html | 55 ++- .../ledgrab/templates/modals/css-editor.html | 44 ++- .../modals/game-integration-editor.html | 184 ++++++---- .../templates/modals/gradient-editor.html | 90 +++-- .../templates/modals/ha-light-editor.html | 208 ++++++----- .../templates/modals/ha-source-editor.html | 127 ++++--- .../templates/modals/mqtt-source-editor.html | 151 ++++---- .../modals/notification-history.html | 18 +- .../templates/modals/pattern-template.html | 102 +++--- .../ledgrab/templates/modals/pp-template.html | 57 ++- .../templates/modals/scene-preset-editor.html | 68 ++-- .../templates/modals/setup-required.html | 74 ++-- .../src/ledgrab/templates/modals/stream.html | 240 ++++++------ .../templates/modals/sync-clock-editor.html | 67 ++-- .../templates/modals/target-editor.html | 196 ++++++---- .../templates/modals/test-audio-source.html | 44 ++- .../templates/modals/test-audio-template.html | 65 ++-- .../templates/modals/test-css-source.html | 156 ++++---- .../templates/modals/test-pp-template.html | 41 ++- .../ledgrab/templates/modals/test-stream.html | 34 +- .../templates/modals/test-template.html | 48 ++- .../templates/modals/test-value-source.html | 50 ++- .../templates/modals/value-source-editor.html | 64 +++- .../modals/weather-source-editor.html | 140 ++++--- .../templates/partials/tutorial-overlay.html | 48 ++- server/tests/test_adalight_client.py | 133 +++++++ server/tests/test_ddp_client.py | 170 +++++++++ 57 files changed, 4020 insertions(+), 1788 deletions(-) create mode 100644 .mcp.json create mode 100644 server/tests/test_adalight_client.py create mode 100644 server/tests/test_ddp_client.py diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c942808 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "code-review-graph": { + "command": "uvx", + "args": [ + "code-review-graph", + "serve" + ], + "type": "stdio" + } + } +} diff --git a/server/src/ledgrab/core/capture/calibration.py b/server/src/ledgrab/core/capture/calibration.py index afcc32f..d783134 100644 --- a/server/src/ledgrab/core/capture/calibration.py +++ b/server/src/ledgrab/core/capture/calibration.py @@ -233,6 +233,90 @@ class CalibrationConfig: return None +def _build_skip_buffers(mapper, calibration: CalibrationConfig, total_leds: int) -> None: + """Pre-compute Phase 3 skip-LED resampling indices and scratch buffers. + + Phase 3 takes the full ``total_leds`` strip and resamples it into + ``active_count = total_leds - skip_start - skip_end`` LEDs using linear + interpolation. We precompute floor/ceil source indices and fractional + weights once so per-frame work becomes a couple of ``np.take`` + + in-place arithmetic ops with no allocations. + + Attaches all skip-related state to ``mapper`` directly to keep the + storage layout consistent between PixelMapper and AdvancedPixelMapper. + """ + skip_start = calibration.skip_leds_start + skip_end = calibration.skip_leds_end + mapper._skip_start = skip_start + mapper._skip_end = skip_end + active_count = max(0, total_leds - skip_start - skip_end) + mapper._active_count = active_count + + if not (0 < active_count < total_leds): + # No skip needed (full strip used) or no active LEDs. + mapper._skip_floor_idx = None + mapper._skip_ceil_idx = None + mapper._skip_frac = None + mapper._skip_left_u8 = None + mapper._skip_right_u8 = None + mapper._skip_blend_f32 = None + mapper._skip_resampled = None + return + + # Floor/ceil source indices and fractional weights for each + # destination LED. ``t = src_x[k] = k * (total_leds - 1) / (active_count - 1)`` + # — equivalent to ``np.linspace(0, total_leds - 1, active_count)``. + if active_count > 1: + t = np.arange(active_count, dtype=np.float64) * ((total_leds - 1) / (active_count - 1)) + else: + t = np.zeros(active_count, dtype=np.float64) + floor_idx = np.floor(t).astype(np.int64) + np.clip(floor_idx, 0, total_leds - 1, out=floor_idx) + ceil_idx = np.minimum(floor_idx + 1, total_leds - 1) + frac = (t - floor_idx).astype(np.float32)[:, None] # (active_count, 1) + + mapper._skip_floor_idx = floor_idx + mapper._skip_ceil_idx = ceil_idx + mapper._skip_frac = frac + # uint8 take destinations + float32 blend scratch — all reused per frame + mapper._skip_left_u8 = np.empty((active_count, 3), dtype=np.uint8) + mapper._skip_right_u8 = np.empty((active_count, 3), dtype=np.uint8) + mapper._skip_blend_f32 = np.empty((active_count, 3), dtype=np.float32) + mapper._skip_resampled = np.empty((active_count, 3), dtype=np.uint8) + + +def _apply_skip_resample(mapper, led_array: np.ndarray) -> None: + """Phase 3 in-place resample of ``led_array`` (no allocations). + + Applies linear interpolation precomputed in ``_build_skip_buffers`` and + writes the result back into ``led_array`` with the configured skip + leading/trailing zeros. + """ + floor_idx = mapper._skip_floor_idx + if floor_idx is None: + if mapper._active_count <= 0: + led_array[:] = 0 + return + + left_u8 = mapper._skip_left_u8 + right_u8 = mapper._skip_right_u8 + blend = mapper._skip_blend_f32 + resampled = mapper._skip_resampled + + np.take(led_array, floor_idx, axis=0, out=left_u8) + np.take(led_array, mapper._skip_ceil_idx, axis=0, out=right_u8) + np.copyto(blend, right_u8, casting="unsafe") # uint8 → float32 + blend -= left_u8 # right - left + blend *= mapper._skip_frac # frac * (right - left) + blend += left_u8 # left + frac*(right - left) + np.clip(blend, 0, 255, out=blend) + np.copyto(resampled, blend, casting="unsafe") # float32 → uint8 + + led_array[:] = 0 + end_idx = mapper._total_leds - mapper._skip_end + led_array[mapper._skip_start : end_idx] = resampled + + class PixelMapper: """Maps screen border pixels to LED colors based on calibration.""" @@ -280,19 +364,10 @@ class PixelMapper: indices = (indices + offset) % total_leds self._segment_indices.append(indices) - # Pre-compute Phase 3 skip arrays (static geometry) - skip_start = calibration.skip_leds_start - skip_end = calibration.skip_leds_end - self._skip_start = skip_start - self._skip_end = skip_end - self._active_count = max(0, total_leds - skip_start - skip_end) - if 0 < self._active_count < total_leds: - self._skip_src = np.linspace(0, total_leds - 1, self._active_count) - self._skip_x = np.arange(total_leds, dtype=np.float64) - self._skip_float = np.empty((total_leds, 3), dtype=np.float64) - self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8) - else: - self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None + # Pre-compute Phase 3 skip — linear interpolation by precomputed + # floor/ceil indices and fractional weights. Per-frame work is + # entirely write-in-place into pre-allocated scratch buffers. + _build_skip_buffers(self, calibration, total_leds) # Per-edge average computation cache (lazy-initialized on first frame) self._edge_cache: Dict[str, tuple] = {} @@ -357,8 +432,9 @@ class PixelMapper: ) -> np.ndarray: """Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8. - Uses pre-allocated cumsum/mean buffers (lazy-initialized per edge) to - avoid per-frame allocations that cause GC-induced timing spikes. + Uses pre-allocated cumsum/mean buffers AND pre-allocated output + buffers (lazy-initialized per edge). All per-frame numpy ops write + in-place — zero allocations on the hot path. """ if edge_name in ("top", "bottom"): axis = 0 @@ -369,7 +445,7 @@ class PixelMapper: # Lazy-init / resize per-edge scratch buffers cache = self._edge_cache.get(edge_name) - if cache is None or cache[0] != edge_len: + if cache is None or cache[0] != edge_len or cache[1] != led_count: step = edge_len / led_count boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64) boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1) @@ -379,20 +455,53 @@ class PixelMapper: lengths = (ends - starts).reshape(-1, 1).astype(np.float64) cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64) edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64) - cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf) + sums_buf = np.empty((led_count, 3), dtype=np.float64) + starts_buf = np.empty((led_count, 3), dtype=np.float64) + out_uint8 = np.empty((led_count, 3), dtype=np.uint8) + cache = ( + edge_len, + led_count, + starts, + ends, + lengths, + cumsum_buf, + edge_1d_buf, + sums_buf, + starts_buf, + out_uint8, + ) self._edge_cache[edge_name] = cache - _, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache + ( + _, + _, + starts, + ends, + lengths, + cumsum_buf, + edge_1d_buf, + sums_buf, + starts_buf, + out_uint8, + ) = cache # Mean into pre-allocated buffer (no intermediate float64 array) np.mean(edge_pixels, axis=axis, out=edge_1d_buf) - # Cumsum into pre-allocated buffer + # Cumsum into pre-allocated buffer (cumsum_buf[0] left at 0 from init) cumsum_buf[0] = 0 np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:]) - segment_sums = cumsum_buf[ends] - cumsum_buf[starts] - return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8) + # segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each + # fancy-index expression allocates. np.take with ``out=`` writes + # directly into our pre-allocated scratch. + np.take(cumsum_buf, ends, axis=0, out=sums_buf) + np.take(cumsum_buf, starts, axis=0, out=starts_buf) + np.subtract(sums_buf, starts_buf, out=sums_buf) + np.divide(sums_buf, lengths, out=sums_buf) + np.clip(sums_buf, 0, 255, out=sums_buf) + np.copyto(out_uint8, sums_buf, casting="unsafe") + return out_uint8 def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray: """Map screen border pixels to LED colors. @@ -423,18 +532,9 @@ class PixelMapper: led_array[self._segment_indices[i]] = colors - # Phase 3: Physical skip — resample full perimeter to active LEDs - if self._skip_src is not None: - np.copyto(self._skip_float, led_array, casting="unsafe") - for ch in range(3): - self._skip_resampled[:, ch] = np.round( - np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch]) - ).astype(np.uint8) - led_array[:] = 0 - end_idx = self._total_leds - self._skip_end - led_array[self._skip_start : end_idx] = self._skip_resampled - elif self._active_count <= 0: - led_array[:] = 0 + # Phase 3: physical skip — resample full perimeter into active LEDs + # using precomputed weights, all in-place. + _apply_skip_resample(self, led_array) return led_array @@ -514,19 +614,8 @@ class AdvancedPixelMapper: self._line_indices.append(indices) led_start += line.led_count - # Skip arrays (same logic as PixelMapper) - skip_start = calibration.skip_leds_start - skip_end = calibration.skip_leds_end - self._skip_start = skip_start - self._skip_end = skip_end - self._active_count = max(0, total_leds - skip_start - skip_end) - if 0 < self._active_count < total_leds: - self._skip_src = np.linspace(0, total_leds - 1, self._active_count) - self._skip_x = np.arange(total_leds, dtype=np.float64) - self._skip_float = np.empty((total_leds, 3), dtype=np.float64) - self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8) - else: - self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None + # Skip arrays — share the same buffer layout as PixelMapper + _build_skip_buffers(self, calibration, total_leds) # Per-line edge cache (keyed by line index to avoid collision) self._edge_cache: Dict[int, tuple] = {} @@ -586,7 +675,7 @@ class AdvancedPixelMapper: edge_len = edge_pixels.shape[0] cache = self._edge_cache.get(cache_key) - if cache is None or cache[0] != edge_len: + if cache is None or cache[0] != edge_len or cache[1] != led_count: step = edge_len / led_count boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64) boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1) @@ -596,15 +685,45 @@ class AdvancedPixelMapper: lengths = (ends - starts).reshape(-1, 1).astype(np.float64) cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64) edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64) - cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf) + sums_buf = np.empty((led_count, 3), dtype=np.float64) + starts_buf = np.empty((led_count, 3), dtype=np.float64) + out_uint8 = np.empty((led_count, 3), dtype=np.uint8) + cache = ( + edge_len, + led_count, + starts, + ends, + lengths, + cumsum_buf, + edge_1d_buf, + sums_buf, + starts_buf, + out_uint8, + ) self._edge_cache[cache_key] = cache - _, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache + ( + _, + _, + starts, + ends, + lengths, + cumsum_buf, + edge_1d_buf, + sums_buf, + starts_buf, + out_uint8, + ) = cache np.mean(edge_pixels, axis=axis, out=edge_1d_buf) cumsum_buf[0] = 0 np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:]) - segment_sums = cumsum_buf[ends] - cumsum_buf[starts] - return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8) + np.take(cumsum_buf, ends, axis=0, out=sums_buf) + np.take(cumsum_buf, starts, axis=0, out=starts_buf) + np.subtract(sums_buf, starts_buf, out=sums_buf) + np.divide(sums_buf, lengths, out=sums_buf) + np.clip(sums_buf, 0, 255, out=sums_buf) + np.copyto(out_uint8, sums_buf, casting="unsafe") + return out_uint8 def _map_edge_fallback( self, @@ -672,18 +791,8 @@ class AdvancedPixelMapper: led_array[self._line_indices[i]] = colors - # Phase 3: Physical skip (same as PixelMapper) - if self._skip_src is not None: - np.copyto(self._skip_float, led_array, casting="unsafe") - for ch in range(3): - self._skip_resampled[:, ch] = np.round( - np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch]) - ).astype(np.uint8) - led_array[:] = 0 - end_idx = self._total_leds - self._skip_end - led_array[self._skip_start : end_idx] = self._skip_resampled - elif self._active_count <= 0: - led_array[:] = 0 + # Phase 3: physical skip — same precomputed-weight resample as PixelMapper + _apply_skip_resample(self, led_array) return led_array diff --git a/server/src/ledgrab/core/devices/adalight_client.py b/server/src/ledgrab/core/devices/adalight_client.py index cca63bc..edb89fd 100644 --- a/server/src/ledgrab/core/devices/adalight_client.py +++ b/server/src/ledgrab/core/devices/adalight_client.py @@ -1,6 +1,7 @@ """Adalight serial LED client — sends pixel data over serial using the Adalight protocol.""" import asyncio +import concurrent.futures from datetime import datetime, timezone from typing import Optional, Tuple @@ -56,15 +57,38 @@ class AdalightClient(LEDClient): # Pre-compute Adalight header if led_count is known self._header = _build_adalight_header(led_count) if led_count > 0 else b"" + self._header_len = len(self._header) - # Pre-allocate numpy buffer for brightness scaling - self._pixel_buf = None + # Pre-allocated wire buffer (header + RGB payload). Resized on the + # first frame and reused thereafter so the hot path performs no + # allocations — only a single memcpy of the pixel bytes. + self._frame_buf: Optional[bytearray] = None + self._frame_buf_n: int = 0 + # Scratch uint8 array used to coerce non-uint8 / non-contiguous input + # without allocating a fresh array per frame. + self._u8_scratch: Optional[np.ndarray] = None + self._u8_scratch_n: int = 0 + # Dedicated single-worker executor for serial writes. Using + # ``loop.run_in_executor`` against this avoids the per-call + # ``contextvars.copy_context()`` and ``functools.partial`` overhead + # that ``asyncio.to_thread`` incurs (~5–10 µs per call), and + # guarantees FIFO ordering of writes from this client even when + # other tasks are using the default executor. + self._tx_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None async def connect(self) -> bool: """Open serial port and wait for Arduino reset.""" try: self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1) - await asyncio.to_thread(self._serial.open) + # Single-worker executor — created here so the thread is bound + # to this client's lifecycle (started on connect, shut down on + # close). ``thread_name_prefix`` makes it identifiable in + # diagnostics. + self._tx_executor = concurrent.futures.ThreadPoolExecutor( + max_workers=1, + thread_name_prefix=f"adalight-tx-{self._port}", + ) + await asyncio.get_running_loop().run_in_executor(self._tx_executor, self._serial.open) # Wait for Arduino to finish bootloader reset (non-blocking). # USB-to-TTL adapters without DTR don't reset, but the delay # is harmless on those — keeps the path uniform. @@ -77,11 +101,22 @@ class AdalightClient(LEDClient): return True except Exception as e: logger.error(f"Failed to open serial port {self._port}: {e}") + if self._tx_executor is not None: + self._tx_executor.shutdown(wait=False) + self._tx_executor = None raise RuntimeError(f"Failed to open serial port {self._port}: {e}") async def close(self) -> None: """Send black frame and close the serial port.""" - if self._connected and self._serial and self._serial.is_open and self._led_count > 0: + loop = asyncio.get_running_loop() + executor = self._tx_executor + if ( + self._connected + and self._serial + and self._serial.is_open + and self._led_count > 0 + and executor is not None + ): try: black = np.zeros((self._led_count, 3), dtype=np.uint8) frame = self._build_frame(black, brightness=255) @@ -89,8 +124,8 @@ class AdalightClient(LEDClient): f"Adalight sending black frame: {self._port} " f"({self._led_count} LEDs, {len(frame)} bytes)" ) - await asyncio.to_thread(self._serial.write, frame) - await asyncio.to_thread(self._serial.flush) + await loop.run_in_executor(executor, self._serial.write, frame) + await loop.run_in_executor(executor, self._serial.flush) logger.info(f"Adalight black frame sent and flushed: {self._port}") except Exception as e: logger.warning(f"Failed to send black frame on close: {e}") @@ -108,6 +143,9 @@ class AdalightClient(LEDClient): except Exception as e: logger.warning(f"Error closing serial port: {e}") self._serial = None + if self._tx_executor is not None: + self._tx_executor.shutdown(wait=False) + self._tx_executor = None logger.info(f"Adalight disconnected: {self._port}") @property @@ -125,12 +163,15 @@ class AdalightClient(LEDClient): pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples brightness: Global brightness (0-255) """ - if not self.is_connected: + executor = self._tx_executor + if not self.is_connected or executor is None: return False try: frame = self._build_frame(pixels, brightness) - await asyncio.to_thread(self._serial.write, frame) + # ``run_in_executor`` skips the per-call ``contextvars.copy_context`` + # / ``functools.partial`` overhead that ``asyncio.to_thread`` does. + await asyncio.get_running_loop().run_in_executor(executor, self._serial.write, frame) return True except Exception as e: logger.error(f"Adalight send_pixels error: {e}") @@ -141,17 +182,63 @@ class AdalightClient(LEDClient): # Serial write is blocking — use async send_pixels path instead return False - def _build_frame(self, pixels, brightness: int) -> bytes: - """Build a complete Adalight frame: header + brightness-scaled RGB data.""" - if isinstance(pixels, np.ndarray): - arr = pixels.astype(np.uint16) - else: - arr = np.array(pixels, dtype=np.uint16) + def _ensure_frame_buf(self, n_leds: int) -> None: + """Lazily allocate / resize the wire-format frame buffer. - # Note: brightness already applied by processor loop (_cached_brightness) - np.clip(arr, 0, 255, out=arr) - rgb_bytes = arr.astype(np.uint8).tobytes() - return self._header + rgb_bytes + Header bytes are written once at the front; subsequent calls only + memcpy the pixel payload into the trailing slot. + """ + needed = self._header_len + n_leds * 3 + if self._frame_buf is None or len(self._frame_buf) != needed: + buf = bytearray(needed) + buf[: self._header_len] = self._header + self._frame_buf = buf + self._frame_buf_n = n_leds + + def _ensure_u8_scratch(self, n_leds: int) -> np.ndarray: + """Pre-allocated (N, 3) uint8 scratch for non-conforming inputs.""" + if self._u8_scratch is None or self._u8_scratch_n != n_leds: + self._u8_scratch = np.empty((n_leds, 3), dtype=np.uint8) + self._u8_scratch_n = n_leds + return self._u8_scratch + + def _build_frame(self, pixels, brightness: int) -> bytes: + """Build a complete Adalight frame in the pre-allocated wire buffer. + + The processor loop hands us a contiguous (N, 3) uint8 array with + brightness already applied (see ``_cached_brightness``), so the hot + path is one memcpy from the pixel buffer into the trailing slot of + ``_frame_buf``. All other input shapes (lists of tuples, wrong + dtype, non-contiguous views) coerce into a pre-allocated uint8 + scratch before the memcpy — still allocation-free in steady state. + """ + if isinstance(pixels, np.ndarray): + n_leds = pixels.shape[0] + if pixels.dtype == np.uint8 and pixels.flags["C_CONTIGUOUS"]: + # Hot path: input matches wire format exactly. + arr = pixels + else: + # Slow path: dtype mismatch or non-contiguous view. Coerce + # into a pre-allocated uint8 scratch. Wider integer dtypes + # are clamped to [0, 255] to match historical behaviour. + arr = self._ensure_u8_scratch(n_leds) + if pixels.dtype != np.uint8: + # Clamp wider integer dtypes to [0, 255] before the + # uint8 narrowing copy. This is the rare slow path — + # one extra allocation here is fine. + np.copyto(arr, np.clip(pixels, 0, 255), casting="unsafe") + else: + np.copyto(arr, pixels) + else: + # List/tuple input — rare path, only hit by tests/legacy callers. + arr = np.array(pixels, dtype=np.uint8) + n_leds = arr.shape[0] + + self._ensure_frame_buf(n_leds) + # memcpy pixel bytes into the trailing slot of the pre-built buffer + view = memoryview(self._frame_buf) + view[self._header_len :] = memoryview(arr).cast("B") + return self._frame_buf @classmethod async def check_health( diff --git a/server/src/ledgrab/core/devices/ddp_client.py b/server/src/ledgrab/core/devices/ddp_client.py index 034484c..b96a4bb 100644 --- a/server/src/ledgrab/core/devices/ddp_client.py +++ b/server/src/ledgrab/core/devices/ddp_client.py @@ -39,6 +39,9 @@ class DDPClient: DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame) DDP_TYPE_RGB = 0x01 + # Pre-built struct.Struct for the 10-byte DDP header (avoids per-call format parsing) + _HEADER_STRUCT = struct.Struct("!BBB B I H") + def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False): """Initialize DDP client. @@ -57,6 +60,10 @@ class DDPClient: # Pre-allocated RGBW buffer (resized on demand) self._rgbw_buf: Optional[np.ndarray] = None self._rgbw_buf_n: int = 0 + # Pre-allocated send buffer (header + payload). Sized lazily on first + # send so we never allocate fresh bytes per frame on the hot path. + self._send_buf: Optional[bytearray] = None + self._send_view: Optional[memoryview] = None async def connect(self): """Establish UDP connection.""" @@ -93,52 +100,52 @@ class DDPClient: f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})" ) - def _build_ddp_packet( - self, - rgb_data: bytes, - offset: int = 0, - sequence: int = 1, - push: bool = False, - ) -> bytes: - """Build a DDP packet. + def _ensure_send_buf(self, capacity: int) -> None: + """Lazily allocate / grow the per-instance send buffer. - DDP packet format (10-byte header + data): - - Byte 0: Flags (VER1 | PUSH on last packet) - - Byte 1: Sequence number - - Byte 2: Data type (0x01 = RGB) - - Byte 3: Source/Destination ID - - Bytes 4-7: Data offset (4 bytes, big-endian) - - Bytes 8-9: Data length (2 bytes, big-endian) - - Bytes 10+: Pixel data - - Args: - rgb_data: RGB pixel data as bytes - offset: Byte offset (pixel_index * 3) - sequence: Sequence number (0-255) - push: True for the last packet of a frame - - Returns: - Complete DDP packet as bytes + ``capacity`` is the largest packet we may emit (header + payload). + Once sized, the buffer is reused for every subsequent send so the + hot path stays allocation-free. """ - flags = self.DDP_FLAGS_VER1 - if push: - flags |= self.DDP_FLAGS_PUSH - data_type = self.DDP_TYPE_RGB - source_id = 0x01 - data_len = len(rgb_data) + buf = self._send_buf + if buf is None or len(buf) < capacity: + self._send_buf = bytearray(capacity) + self._send_view = memoryview(self._send_buf) - # Build header (10 bytes) - header = struct.pack( - "!BBB B I H", # Network byte order (big-endian) - flags, # Flags - sequence, # Sequence - data_type, # Data type - source_id, # Source/Destination - offset, # Data offset (4 bytes) - data_len, # Data length (2 bytes) + def _emit_packet( + self, + payload: memoryview, + offset: int, + sequence: int, + push: bool, + ) -> None: + """Pack header + payload into the pre-allocated send buffer and emit. + + DDP packet layout (10-byte header): + [0] Flags (VER1 | PUSH on last) + [1] Sequence + [2] Data type (0x01 = RGB) + [3] Source/Destination ID + [4-7] Data offset (big-endian) + [8-9] Data length (big-endian) + [10+] Pixel data + """ + flags = self.DDP_FLAGS_VER1 | (self.DDP_FLAGS_PUSH if push else 0) + data_len = len(payload) + self._ensure_send_buf(10 + data_len) + buf = self._send_buf + view = self._send_view + # Fill header into pre-allocated buffer (no allocation) + self._HEADER_STRUCT.pack_into( + buf, 0, flags, sequence, self.DDP_TYPE_RGB, 0x01, offset, data_len ) - - return header + rgb_data + # Copy payload bytes into buffer (single memcpy) + view[10 : 10 + data_len] = payload + # asyncio's selector_datagram_transport.sendto fast-path calls + # socket.sendto(data) which accepts a buffer-like; it only copies to + # bytes when the OS send buffer is full and the datagram must be + # queued. So passing a memoryview is safe and avoids `bytes(...)`. + self._transport.sendto(view[: 10 + data_len]) def _reorder_pixels_numpy(self, pixel_array: np.ndarray) -> np.ndarray: """Apply per-bus color order reordering using numpy fancy indexing. @@ -168,13 +175,39 @@ class DDPClient: return result + def _send_buffer(self, payload_view: memoryview, bpp: int, max_packet_size: int) -> int: + """Chunk and emit a contiguous payload via DDP. Returns packet count. + + ``payload_view`` is a 1-D bytes-like view; the caller guarantees its + length is a multiple of ``bpp``. Each emitted packet is sized to a + whole number of pixels so RGB channels never split across packets. + """ + total_bytes = len(payload_view) + max_payload = max_packet_size - 10 # 10-byte header + bytes_per_packet = (max_payload // bpp) * bpp + if bytes_per_packet <= 0: + bytes_per_packet = bpp # degenerate guard + + num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet + for i in range(num_packets): + start = i * bytes_per_packet + end = total_bytes if (i == num_packets - 1) else (start + bytes_per_packet) + self._sequence = (self._sequence + 1) % 256 + self._emit_packet( + payload_view[start:end], + offset=start, + sequence=self._sequence, + push=(i == num_packets - 1), + ) + return num_packets + async def send_pixels( self, pixels: List[Tuple[int, int, int]], max_packet_size: int = 1400 ) -> bool: """Send pixel data via DDP. Args: - pixels: List of (R, G, B) tuples + pixels: List of (R, G, B) tuples or an (N, 3) uint8 numpy array max_packet_size: Maximum UDP packet size (default 1400 bytes for safety) Returns: @@ -187,65 +220,17 @@ class DDPClient: raise RuntimeError("DDP client not connected") try: - # Send plain RGB — WLED handles per-bus color order conversion - # internally when outputting to hardware. # Accept numpy arrays directly to avoid per-pixel Python loop - bpp = 4 if self.rgbw else 3 # bytes per pixel if isinstance(pixels, np.ndarray): pixel_array = pixels else: pixel_array = np.array(pixels, dtype=np.uint8) - if self.rgbw: - n = pixel_array.shape[0] - if n != self._rgbw_buf_n: - self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8) - self._rgbw_buf_n = n - self._rgbw_buf[:, :3] = pixel_array - pixel_array = self._rgbw_buf - pixel_bytes = pixel_array.tobytes() - - total_bytes = len(pixel_bytes) - # Align payload to full pixels (multiple of bpp) to avoid splitting - # a pixel's channels across packets - max_payload = max_packet_size - 10 # 10-byte header - bytes_per_packet = (max_payload // bpp) * bpp - - # Split into multiple packets if needed - num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet - - logger.debug( - f"DDP: Sending {len(pixels)} pixels ({total_bytes} bytes) " - f"in {num_packets} packet(s) to {self.host}:{self.port}" - ) - - for i in range(num_packets): - start = i * bytes_per_packet - end = min(start + bytes_per_packet, total_bytes) - chunk = pixel_bytes[start:end] - is_last = i == num_packets - 1 - - # Increment sequence number - self._sequence = (self._sequence + 1) % 256 - - # Set PUSH flag on the last packet to signal frame completion - packet = self._build_ddp_packet( - chunk, - offset=start, - sequence=self._sequence, - push=is_last, - ) - self._transport.sendto(packet) - - logger.debug( - f"Sent DDP packet {i+1}/{num_packets}: " - f"{len(chunk)} bytes at offset {start}" - f"{' [PUSH]' if is_last else ''}" - ) + self.send_pixels_numpy(pixel_array, max_packet_size=max_packet_size) return True except Exception as e: - logger.error(f"Failed to send DDP pixels: {e}") + logger.error("Failed to send DDP pixels: %s", e) raise RuntimeError(f"DDP send failed: {e}") def send_pixels_numpy(self, pixel_array: np.ndarray, max_packet_size: int = 1400) -> bool: @@ -270,28 +255,15 @@ class DDPClient: self._rgbw_buf[:, :3] = pixel_array pixel_array = self._rgbw_buf - pixel_bytes = pixel_array.tobytes() - bpp = 4 if self.rgbw else 3 - total_bytes = len(pixel_bytes) - max_payload = max_packet_size - 10 # 10-byte header - bytes_per_packet = (max_payload // bpp) * bpp - num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet - - for i in range(num_packets): - start = i * bytes_per_packet - end = min(start + bytes_per_packet, total_bytes) - chunk = pixel_bytes[start:end] - is_last = i == num_packets - 1 - self._sequence = (self._sequence + 1) % 256 - packet = self._build_ddp_packet( - chunk, - offset=start, - sequence=self._sequence, - push=is_last, - ) - self._transport.sendto(packet) - + # Get a 1-D bytes view of the pixel buffer with no allocation when + # the array is already C-contiguous (the common case). + if not pixel_array.flags["C_CONTIGUOUS"]: + pixel_array = np.ascontiguousarray(pixel_array) + # ``cast('B')`` on a memoryview of a numpy array returns a 1-D byte + # view; total length == nbytes. + payload_view = memoryview(pixel_array).cast("B") + self._send_buffer(payload_view, bpp, max_packet_size) return True async def __aenter__(self): diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py index e2cd4b9..534b8cf 100644 --- a/server/src/ledgrab/core/processing/wled_target_processor.py +++ b/server/src/ledgrab/core/processing/wled_target_processor.py @@ -82,10 +82,17 @@ class WledTargetProcessor(TargetProcessor): self._resolved_display_index: Optional[int] = None self._device_config = None # populated on start(), typed DeviceConfig - # Fit-to-device linspace cache (per-instance to avoid cross-target thrash) + # Fit-to-device cache (per-instance to avoid cross-target thrash). + # Holds precomputed floor/ceil source indices, fractional weights, + # and reusable scratch buffers so the per-frame interpolation runs + # entirely with in-place numpy ops — no allocations. self._fit_cache_key: tuple = (0, 0) - self._fit_cache_src: Optional[np.ndarray] = None - self._fit_cache_dst: Optional[np.ndarray] = None + self._fit_floor_idx: Optional[np.ndarray] = None + self._fit_ceil_idx: Optional[np.ndarray] = None + self._fit_frac: Optional[np.ndarray] = None + self._fit_left_u8: Optional[np.ndarray] = None + self._fit_right_u8: Optional[np.ndarray] = None + self._fit_blend_f32: Optional[np.ndarray] = None self._fit_result_buf: Optional[np.ndarray] = None # LED preview WebSocket clients @@ -384,6 +391,69 @@ class WledTargetProcessor(TargetProcessor): logger.debug("Device probe failed for %s: %s", device_url, e) return False + async def _run_liveness_probe_loop(self, device_url: str, probe_interval: float = 10.0) -> None: + """Background loop that probes the device and updates adaptive state. + + Runs independently from the per-frame processing loop so the hot + path doesn't pay for `_probe_task.done()` / scheduling checks every + iteration. Updates ``self._device_reachable``, + ``self._metrics.device_streaming_reachable`` and (when adaptive FPS + is enabled) ``self._effective_fps`` directly. + """ + async with httpx.AsyncClient(timeout=httpx.Timeout(2.0)) as client: + while self._is_running: + try: + reachable = await self._probe_device(device_url, client) + except asyncio.CancelledError: + raise + except Exception: + reachable = False + + prev_reachable = self._device_reachable + self._device_reachable = reachable + self._metrics.device_streaming_reachable = reachable + + if self._adaptive_fps: + target_fps = self._target_fps if self._target_fps > 0 else 30 + if not reachable: + old_eff = self._effective_fps + new_eff = max(1, self._effective_fps // 2) + if old_eff != new_eff: + self._effective_fps = new_eff + logger.warning( + "[ADAPTIVE] %s device unreachable, FPS %d → %d", + self._target_id, + old_eff, + new_eff, + ) + elif self._effective_fps < target_fps: + step = max(1, target_fps // 8) + old_eff = self._effective_fps + new_eff = min(target_fps, self._effective_fps + step) + if old_eff != new_eff: + self._effective_fps = new_eff + logger.info( + "[ADAPTIVE] %s device reachable, FPS %d → %d", + self._target_id, + old_eff, + new_eff, + ) + + if prev_reachable != reachable: + logger.info( + "[PROBE] %s device %s", + self._target_id, + "reachable" if reachable else "UNREACHABLE", + ) + + # Cooperative sleep that promptly notices stop(). + # Sleep in 0.5s chunks so cancellation latency stays < 0.5s. + slept = 0.0 + while slept < probe_interval and self._is_running: + chunk = min(0.5, probe_interval - slept) + await asyncio.sleep(chunk) + slept += chunk + def get_display_index(self) -> Optional[int]: """Display index being captured, from the active stream.""" if self._resolved_display_index is not None: @@ -646,24 +716,57 @@ class WledTargetProcessor(TargetProcessor): # ----- Private: processing loop ----- def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray: - """Resample colors to match the target LED count.""" + """Resample colors to match the target LED count. + + Linear interpolation using floor/ceil source indices and fractional + weights — all precomputed when ``(n, device_led_count)`` changes. + Per-frame work is two ``np.take`` calls and a few in-place ops on + pre-allocated scratch buffers. No per-frame allocations. + """ n = len(colors) if n == device_led_count or device_led_count <= 0: return colors + key = (n, device_led_count) if self._fit_cache_key != key: - self._fit_cache_src = np.linspace(0, 1, n) - self._fit_cache_dst = np.linspace(0, 1, device_led_count) - self._fit_cache_key = key + if device_led_count > 1 and n > 1: + t = np.arange(device_led_count, dtype=np.float64) * ( + (n - 1) / (device_led_count - 1) + ) + else: + t = np.zeros(device_led_count, dtype=np.float64) + floor_idx = np.floor(t).astype(np.int64) + np.clip(floor_idx, 0, n - 1, out=floor_idx) + ceil_idx = np.minimum(floor_idx + 1, n - 1) + frac = (t - floor_idx).astype(np.float32)[:, None] # (M, 1) for channel broadcast + self._fit_floor_idx = floor_idx + self._fit_ceil_idx = ceil_idx + self._fit_frac = frac + self._fit_left_u8 = np.empty((device_led_count, 3), dtype=np.uint8) + self._fit_right_u8 = np.empty((device_led_count, 3), dtype=np.uint8) + self._fit_blend_f32 = np.empty((device_led_count, 3), dtype=np.float32) self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8) - buf = self._fit_result_buf - for ch in range(min(colors.shape[1], 3)): - np.copyto( - buf[:, ch], - np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]), - casting="unsafe", - ) - return buf + self._fit_cache_key = key + + # Source slice: ColorStripStreams produce (N, 3); guard against (N, 4) RGBA. + rgb = colors[:, :3] if colors.ndim == 2 and colors.shape[1] > 3 else colors + + left_u8 = self._fit_left_u8 + right_u8 = self._fit_right_u8 + blend = self._fit_blend_f32 + out = self._fit_result_buf + + # uint8 → uint8 take with `out=` — no allocation + np.take(rgb, self._fit_floor_idx, axis=0, out=left_u8) + np.take(rgb, self._fit_ceil_idx, axis=0, out=right_u8) + # Promote right to float32 in pre-allocated scratch + np.copyto(blend, right_u8, casting="unsafe") # blend = right (float32) + blend -= left_u8 # blend = right - left + blend *= self._fit_frac # blend = frac * (right - left) + blend += left_u8 # blend = left + frac * (right - left) + np.clip(blend, 0, 255, out=blend) + np.copyto(out, blend, casting="unsafe") # float32 → uint8 + return out async def _send_to_device(self, send_colors: np.ndarray) -> float: """Send colors to LED device and return send time in ms.""" @@ -785,14 +888,16 @@ class WledTargetProcessor(TargetProcessor): _diag_slow_iters: collections.deque = collections.deque(maxlen=50) _diag_iter_times: collections.deque = collections.deque(maxlen=300) # --- Liveness probe + adaptive FPS --- + # The probe runs as an independent task so the hot loop doesn't + # pay for per-iteration probe-state checks. _device_url = self._device_config.device_url if self._device_config else "" _probe_enabled = _device_url.startswith("http") - _probe_interval = 10.0 # seconds between probes - _last_probe_time = 0.0 # force first probe soon (after 10s) _probe_task: Optional[asyncio.Task] = None - _probe_client: Optional[httpx.AsyncClient] = None if _probe_enabled: - _probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0)) + _probe_task = asyncio.create_task( + self._run_liveness_probe_loop(_device_url), + name=f"liveness-probe-{self._target_id}", + ) self._effective_fps = self._target_fps self._device_reachable = None @@ -816,63 +921,8 @@ class WledTargetProcessor(TargetProcessor): loop_start = now = time.perf_counter() target_fps = self._target_fps if self._target_fps > 0 else 30 - # --- Liveness probe --- - # Collect result as soon as it's done (every iteration) - if _probe_task is not None and _probe_task.done(): - try: - reachable = _probe_task.result() - except Exception: - reachable = False - prev_reachable = self._device_reachable - self._device_reachable = reachable - self._metrics.device_streaming_reachable = reachable - _probe_task = None - - if self._adaptive_fps: - if not reachable: - # Backoff: halve effective FPS - old_eff = self._effective_fps - self._effective_fps = max(1, self._effective_fps // 2) - if old_eff != self._effective_fps: - logger.warning( - f"[ADAPTIVE] {self._target_id} device unreachable, " - f"FPS {old_eff} → {self._effective_fps}" - ) - next_frame_time = time.perf_counter() - else: - # Recovery: gradually increase - if self._effective_fps < target_fps: - step = max(1, target_fps // 8) - old_eff = self._effective_fps - self._effective_fps = min( - target_fps, self._effective_fps + step - ) - if old_eff != self._effective_fps: - logger.info( - f"[ADAPTIVE] {self._target_id} device reachable, " - f"FPS {old_eff} → {self._effective_fps}" - ) - next_frame_time = time.perf_counter() - - if prev_reachable != reachable: - logger.info( - f"[PROBE] {self._target_id} device " - f"{'reachable' if reachable else 'UNREACHABLE'}" - ) - - # Fire new probe every _probe_interval seconds - if ( - _probe_enabled - and _probe_task is None - and (now - _last_probe_time) >= _probe_interval - ): - if _probe_client is not None: - _last_probe_time = now - _probe_task = asyncio.create_task( - self._probe_device(_device_url, _probe_client) - ) - - # Use effective FPS for frame timing + # Use effective FPS for frame timing. ``self._effective_fps`` + # is mutated by the liveness probe task — read once. effective_fps = self._effective_fps if self._adaptive_fps else target_fps self._metrics.fps_effective = effective_fps frame_time = 1.0 / effective_fps @@ -992,8 +1042,8 @@ class WledTargetProcessor(TargetProcessor): await self._broadcast_led_preview(send_colors, cur_brightness) _last_preview_broadcast = now self._metrics.frames_skipped += 1 - self._metrics.fps_current = _fps_current_from_timestamps() await asyncio.sleep(SKIP_REPOLL) + self._metrics.fps_current = _fps_current_from_timestamps() continue # Force-send preview when a new client just connected @@ -1035,10 +1085,10 @@ class WledTargetProcessor(TargetProcessor): await self._broadcast_led_preview(send_colors, cur_brightness) _last_preview_broadcast = now self._metrics.frames_skipped += 1 - self._metrics.fps_current = _fps_current_from_timestamps() is_animated = stream.is_animated repoll = SKIP_REPOLL if is_animated else frame_time await asyncio.sleep(repoll) + self._metrics.fps_current = _fps_current_from_timestamps() continue prev_frame_ref = frame @@ -1161,9 +1211,9 @@ class WledTargetProcessor(TargetProcessor): ) raise finally: - # Clean up probe client - if _probe_client is not None: - await _probe_client.aclose() + # Stop the liveness probe task. ``_run_liveness_probe_loop`` + # owns its own httpx.AsyncClient via ``async with`` so cancelling + # the task closes the client cleanly. if _probe_task is not None and not _probe_task.done(): _probe_task.cancel() try: diff --git a/server/src/ledgrab/static/css/appearance.css b/server/src/ledgrab/static/css/appearance.css index fc1dbc8..28f2373 100644 --- a/server/src/ledgrab/static/css/appearance.css +++ b/server/src/ledgrab/static/css/appearance.css @@ -22,8 +22,8 @@ h1 { on desktop and gracefully reflows on narrow viewports. */ .ap-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 6px; margin-top: 6px; } @@ -35,8 +35,8 @@ h1 { display: flex; flex-direction: column; align-items: stretch; - gap: 5px; - padding: 5px 5px 4px; + gap: 4px; + padding: 4px 4px 3px; border: 1px solid var(--lux-line, var(--border-color)); border-radius: var(--lux-r-md, 8px); background: var(--lux-bg-1, var(--card-bg)); @@ -54,7 +54,7 @@ h1 { .ap-card.active { border: 2px solid var(--ap-ch); - padding: 4px 4px 3px; + padding: 3px 3px 2px; box-shadow: 0 0 0 1px color-mix(in srgb, var(--ap-ch) 40%, transparent), 0 0 16px -4px color-mix(in srgb, var(--ap-ch) 50%, transparent); } @@ -62,19 +62,19 @@ h1 { .ap-card.active::after { content: '\2713'; position: absolute; - top: 4px; - right: 6px; - font-size: 0.65rem; + top: 3px; + right: 4px; + font-size: 0.55rem; font-weight: 700; color: var(--ap-ch); } .ap-card-label { - font-size: 0.7rem; + font-size: 0.62rem; font-weight: 600; color: var(--lux-ink-dim, var(--text-secondary)); text-align: center; - line-height: 1.2; + line-height: 1.15; letter-spacing: 0.02em; } @@ -89,24 +89,24 @@ h1 { aspect-ratio: 1 / 1; border-radius: var(--lux-r-sm, 4px); border: 1px solid; - padding: 7px 6px 5px; + padding: 5px 5px 4px; display: flex; flex-direction: column; - gap: 4px; + gap: 3px; overflow: hidden; } .ap-card-accent { - width: 24px; - height: 4px; + width: 18px; + height: 3px; border-radius: 2px; - margin-bottom: 2px; + margin-bottom: 1px; } .ap-card-lines { display: flex; flex-direction: column; - gap: 3px; + gap: 2px; } .ap-card-lines span { diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 6b8d011..ab9db41 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -3826,10 +3826,45 @@ body.composite-layer-dragging .composite-layer-drag-handle { .ds-section { animation: dsSectionIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; } - .ds-section[data-ds-key="identity"] { animation-delay: 0.02s; } - .ds-section[data-ds-key="connection"] { animation-delay: 0.06s; } - .ds-section[data-ds-key="hardware"] { animation-delay: 0.10s; } - .ds-section[data-ds-key="behavior"] { animation-delay: 0.14s; } + .ds-section[data-ds-key="identity"] { animation-delay: 0.02s; } + .ds-section[data-ds-key="connection"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="hardware"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="behavior"] { animation-delay: 0.14s; } + .ds-section[data-ds-key="targets"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="triggers"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="action"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="deactivation"] { animation-delay: 0.14s; } + .ds-section[data-ds-key="provider"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="refresh"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="filters"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="routing"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="output"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="filtering"] { animation-delay: 0.14s; } + .ds-section[data-ds-key="broker"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="adapter"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="mappings"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="diagnostics"] { animation-delay: 0.14s; } + .ds-section[data-ds-key="source"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="engine"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="timing"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="gradient"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="layout"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="strip"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="type"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="notes"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="file"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="auth"] { animation-delay: 0.02s; } + .ds-section[data-ds-key="configure"] { animation-delay: 0.02s; } + .ds-section[data-ds-key="restart"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="loopback"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="test-setup"] { animation-delay: 0.02s; } + .ds-section[data-ds-key="offsets"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="line-properties"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="test"] { animation-delay: 0.02s; } + .ds-section[data-ds-key="preview"] { animation-delay: 0.02s; } + .ds-section[data-ds-key="controls"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="history"] { animation-delay: 0.02s; } } @keyframes dsSectionIn { diff --git a/server/src/ledgrab/static/css/tutorials.css b/server/src/ledgrab/static/css/tutorials.css index 1ea4826..9f1eb1d 100644 --- a/server/src/ledgrab/static/css/tutorials.css +++ b/server/src/ledgrab/static/css/tutorials.css @@ -195,3 +195,346 @@ } /* target z-index for fixed overlay is set inline via JS (target is outside overlay DOM) */ + + +/* ────────────────────────────────────────────────────────────────────── + v2 "Signal Bench" — opt-in via .tutorial-v2 on the overlay root. + Keeps the legacy ring+tooltip CSS untouched so unmigrated tours keep + working. Aligns with the lux/instrument language used elsewhere in + the UI (hairlines, JetBrains Mono labels, --ch-signal accent). + ────────────────────────────────────────────────────────────────────── */ + +/* Backdrop: dimmer page + a hint of grain so the cutout reads as an + instrument viewfinder instead of a flat hole-punch. */ +.tutorial-overlay.tutorial-v2 .tutorial-backdrop { + background: + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.018) 0px, + rgba(255, 255, 255, 0.018) 1px, + transparent 1px, + transparent 3px + ), + radial-gradient( + 1400px 900px at 50% 35%, + rgba(0, 0, 0, 0.78) 0%, + rgba(0, 0, 0, 0.92) 100% + ); + transition: clip-path 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} + +[data-theme="light"] .tutorial-overlay.tutorial-v2 .tutorial-backdrop { + background: + repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.02) 0px, + rgba(0, 0, 0, 0.02) 1px, + transparent 1px, + transparent 3px + ), + radial-gradient( + 1400px 900px at 50% 35%, + rgba(20, 24, 30, 0.55) 0%, + rgba(20, 24, 30, 0.72) 100% + ); +} + +/* Ring becomes a hairline + signal-glow frame; the prominent visual is + the 4 corner brackets layered on top. */ +.tutorial-overlay.tutorial-v2 .tutorial-ring { + border: 1px dashed color-mix(in srgb, var(--ch-signal) 38%, transparent); + border-radius: 2px; + animation: none; + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--ch-signal) 14%, transparent), + 0 0 22px color-mix(in srgb, var(--ch-signal) 28%, transparent), + inset 0 0 0 1px color-mix(in srgb, var(--ch-signal) 6%, transparent); + transition: + left 0.28s cubic-bezier(0.22, 1, 0.36, 1), + top 0.28s cubic-bezier(0.22, 1, 0.36, 1), + width 0.28s cubic-bezier(0.22, 1, 0.36, 1), + height 0.28s cubic-bezier(0.22, 1, 0.36, 1); +} + +.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner { + position: absolute; + width: 16px; + height: 16px; + border: 2px solid var(--ch-signal); + pointer-events: none; + filter: drop-shadow(0 0 6px color-mix(in srgb, var(--ch-signal) 55%, transparent)); +} +.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.tl { + top: -2px; left: -2px; + border-right: none; border-bottom: none; +} +.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.tr { + top: -2px; right: -2px; + border-left: none; border-bottom: none; +} +.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.bl { + bottom: -2px; left: -2px; + border-right: none; border-top: none; +} +.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.br { + bottom: -2px; right: -2px; + border-left: none; border-top: none; +} + +/* Animate corner draw-in on each step change (ring already eases its + bounds; this adds a snappy "lock" on top). */ +.tutorial-overlay.tutorial-v2.step-changed .tutorial-reticle-corner { + animation: tutorial-corner-lock 0.26s cubic-bezier(0.22, 1, 0.36, 1) both; +} +@keyframes tutorial-corner-lock { + 0% { opacity: 0; transform: scale(2.4); } + 60% { opacity: 1; transform: scale(0.85); } + 100% { opacity: 1; transform: scale(1); } +} + +/* Patch cable — connects reticle to tooltip. Animated dash-offset reads + as transmission flowing toward the tooltip. Absolute by default for + modal-mode overlays; viewport-fixed for tour overlays via the + .tutorial-overlay-fixed class. */ +.tutorial-overlay.tutorial-v2 .tutorial-cable { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 101; + overflow: visible; +} +.tutorial-overlay-fixed.tutorial-v2 .tutorial-cable { + position: fixed; +} +.tutorial-overlay.tutorial-v2 .tutorial-cable line { + stroke: var(--ch-signal); + stroke-width: 1.25; + stroke-dasharray: 4 5; + stroke-linecap: round; + fill: none; + opacity: 0.7; + filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ch-signal) 50%, transparent)); + animation: tutorial-cable-flow 1.4s linear infinite; + transition: all 0.28s cubic-bezier(0.22, 1, 0.36, 1); +} +@keyframes tutorial-cable-flow { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: -18; } +} + +/* Tooltip — instrument readout: square corners, hairline border, + inner ring, lux shadow. */ +.tutorial-overlay.tutorial-v2 .tutorial-tooltip { + width: 296px; + background: var(--card-bg); + border: 1px solid var(--lux-line-bold); + border-radius: 2px; + box-shadow: + 0 0 0 1px var(--lux-bg-2), + var(--lux-shadow-rack, 0 8px 24px rgba(0, 0, 0, 0.5)), + 0 0 32px color-mix(in srgb, var(--ch-signal) 12%, transparent); + animation: tutorial-tooltip-v2-in 0.28s cubic-bezier(0.22, 1, 0.36, 1); + overflow: hidden; + position: relative; +} +.tutorial-overlay.tutorial-v2 .tutorial-tooltip::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient( + 90deg, + transparent 0%, + var(--ch-signal) 20%, + var(--ch-signal) 80%, + transparent 100% + ); + opacity: 0.85; +} +@keyframes tutorial-tooltip-v2-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.tutorial-overlay.tutorial-v2 .tutorial-tooltip-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--lux-line); +} +.tutorial-overlay.tutorial-v2 .tutorial-tooltip-eyebrow { + flex: 1; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + color: var(--ch-signal); + text-transform: uppercase; +} +.tutorial-overlay.tutorial-v2 .tutorial-step-counter { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.06em; + color: var(--lux-ink-dim); + font-variant-numeric: tabular-nums; +} +.tutorial-tooltip-breadcrumb { + display: none; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-muted)); + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tutorial-tooltip-breadcrumb.is-visible { + display: inline-block; +} +.tutorial-tooltip-breadcrumb.is-visible + .tutorial-step-counter::before { + content: '· '; + color: var(--lux-ink-mute, var(--text-muted)); + margin-right: 4px; +} +.tutorial-overlay.tutorial-v2 .tutorial-close-btn { + color: var(--lux-ink-mute); + border-radius: 2px; +} +.tutorial-overlay.tutorial-v2 .tutorial-close-btn:hover { + color: var(--lux-ink); + background: var(--lux-bg-2); +} + +/* Segmented progress pips — one slot per step. */ +.tutorial-overlay.tutorial-v2 .tutorial-pips { + display: flex; + gap: 3px; + padding: 10px 12px 4px; +} +.tutorial-overlay.tutorial-v2 .tutorial-pip { + flex: 1; + height: 3px; + background: var(--lux-line); + border-radius: 1px; + transition: background 0.2s ease, box-shadow 0.2s ease; +} +.tutorial-overlay.tutorial-v2 .tutorial-pip.done { + background: color-mix(in srgb, var(--ch-signal) 60%, var(--lux-line)); +} +.tutorial-overlay.tutorial-v2 .tutorial-pip.active { + background: var(--ch-signal); + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal) 60%, transparent); +} + +.tutorial-overlay.tutorial-v2 .tutorial-tooltip-text { + margin: 0; + padding: 8px 14px 14px; + line-height: 1.55; + color: var(--lux-ink); + font-size: 13.5px; + font-weight: 400; + letter-spacing: 0.005em; +} + +.tutorial-overlay.tutorial-v2 .tutorial-tooltip-nav { + display: flex; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--lux-line); +} +.tutorial-overlay.tutorial-v2 .tutorial-prev-btn, +.tutorial-overlay.tutorial-v2 .tutorial-next-btn { + flex: 1; + padding: 7px 10px; + border: 1px solid var(--lux-line-bold); + border-radius: 2px; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} +.tutorial-overlay.tutorial-v2 .tutorial-prev-btn { + background: transparent; + color: var(--lux-ink-dim); +} +.tutorial-overlay.tutorial-v2 .tutorial-prev-btn:hover:not(:disabled) { + color: var(--lux-ink); + border-color: var(--lux-ink-mute); + background: var(--lux-bg-2); +} +.tutorial-overlay.tutorial-v2 .tutorial-next-btn { + background: var(--ch-signal); + color: var(--primary-contrast, #000); + border-color: var(--ch-signal); +} +.tutorial-overlay.tutorial-v2 .tutorial-next-btn:hover:not(:disabled) { + box-shadow: 0 0 16px color-mix(in srgb, var(--ch-signal) 50%, transparent); + filter: brightness(1.06); +} +.tutorial-overlay.tutorial-v2 .tutorial-prev-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* Keyboard hint chip — surfaces existing arrow/Esc bindings. */ +.tutorial-overlay.tutorial-v2 .tutorial-keyhint { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px 10px; + border-top: 1px solid var(--lux-line); + background: var(--lux-bg-1); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.1em; + color: var(--lux-ink-mute); + text-transform: uppercase; +} +.tutorial-overlay.tutorial-v2 .tutorial-keyhint kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + background: var(--lux-bg-3); + border: 1px solid var(--lux-line-bold); + border-radius: 2px; + font: inherit; + font-size: 10px; + color: var(--lux-ink-dim); +} +.tutorial-overlay.tutorial-v2 .tutorial-keyhint span { + margin-right: 6px; +} +.tutorial-overlay.tutorial-v2 .tutorial-keyhint span:last-child { + margin-right: 0; +} + +/* Mobile — collapse cable, dock tooltip to bottom of viewport. */ +@media (max-width: 640px) { + .tutorial-overlay.tutorial-v2 .tutorial-cable { display: none; } + .tutorial-overlay.tutorial-v2 .tutorial-tooltip { + width: calc(100vw - 24px); + max-width: 360px; + } + .tutorial-overlay.tutorial-v2 .tutorial-keyhint { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .tutorial-overlay.tutorial-v2 .tutorial-cable line { animation: none; } + .tutorial-overlay.tutorial-v2 .tutorial-tooltip { animation: none; } + .tutorial-overlay.tutorial-v2.step-changed .tutorial-reticle-corner { animation: none; } + .tutorial-overlay.tutorial-v2 .tutorial-ring { transition: none; } +} diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 55af99a..eab5e6e 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -34,6 +34,7 @@ import { import { startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial, startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial, + startIntegrationsTutorial, closeTutorial, tutorialNext, tutorialPrev, } from './features/tutorials.ts'; @@ -289,6 +290,7 @@ Object.assign(window, { startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial, + startIntegrationsTutorial, closeTutorial, tutorialNext, tutorialPrev, diff --git a/server/src/ledgrab/static/js/core/state.ts b/server/src/ledgrab/static/js/core/state.ts index c901e4d..199569e 100644 --- a/server/src/ledgrab/static/js/core/state.ts +++ b/server/src/ledgrab/static/js/core/state.ts @@ -142,14 +142,35 @@ export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v; export const ledPreviewWebSockets: Record = {}; // Tutorial state +export interface TutorialStepShape { + selector: string; + textKey: string; + position: string; + global?: boolean; + /** Optional sub-tab to switch to before highlighting. Lets the + * framework reveal panels that live behind tab switches. */ + subTab?: string; +} export interface TutorialState { - steps: { selector: string; textKey: string; position: string; global?: boolean }[]; + steps: TutorialStepShape[]; overlay: HTMLElement; mode: string; step: number; - resolveTarget: (step: { selector: string; textKey: string; position: string; global?: boolean }) => Element | null; + resolveTarget: (step: TutorialStepShape) => Element | null; container: Element | null; onClose: (() => void) | null; + /** Called with `step.subTab` before each step. Lets the tour + * reveal panels by switching the relevant sub-tab. */ + switchSubTab: ((key: string) => void) | null; + /** CSS selector pointing at an element whose `textContent` is + * the current sub-tab label (e.g. `.tree-dd-trigger-title`). + * Rendered into the tooltip header as a breadcrumb so the user + * knows which panel is being highlighted. */ + breadcrumbSelector: string | null; + /** Called with the current step before resolving the target. + * Lets a tour open/close auxiliary UI (e.g. side panels, popups) + * so steps inside that UI can be highlighted. */ + prepare: ((step: TutorialStepShape) => void) | null; } export let activeTutorial: TutorialState | null = null; export function setActiveTutorial(v: TutorialState | null) { activeTutorial = v; } diff --git a/server/src/ledgrab/static/js/features/advanced-calibration.ts b/server/src/ledgrab/static/js/features/advanced-calibration.ts index e9e6e1b..a2c1832 100644 --- a/server/src/ledgrab/static/js/features/advanced-calibration.ts +++ b/server/src/ledgrab/static/js/features/advanced-calibration.ts @@ -475,12 +475,15 @@ function _renderLineList(): void { function _showLineProps(): void { const propsEl = document.getElementById('advcal-line-props')!; + const sectionEl = document.getElementById('advcal-line-props-section'); const idx = _state.selectedLine; if (idx < 0 || idx >= _state.lines.length) { propsEl.style.display = 'none'; + if (sectionEl) sectionEl.style.display = 'none'; return; } propsEl.style.display = ''; + if (sectionEl) sectionEl.style.display = ''; const line = _state.lines[idx]; (document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id; if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh(); diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index b2c5beb..6baf64e 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -55,6 +55,8 @@ class CalibrationModal extends Modal { (document.getElementById('calibration-css-id') as HTMLInputElement).value = ''; const testGroup = document.getElementById('calibration-css-test-group'); if (testGroup) testGroup.style.display = 'none'; + const testSection = document.getElementById('calibration-test-setup-section'); + if (testSection) testSection.style.display = 'none'; } else { const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value; if (deviceId) clearTestMode(deviceId); @@ -162,6 +164,8 @@ export async function showCalibration(deviceId: any) { (document.getElementById('calibration-device-id') as HTMLInputElement).value = device.id; (document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = device.led_count; (document.getElementById('cal-css-led-count-group') as HTMLElement).style.display = 'none'; + const testSectionDevMode = document.getElementById('calibration-test-setup-section'); + if (testSectionDevMode) testSectionDevMode.style.display = 'none'; (document.getElementById('calibration-overlay-btn') as HTMLElement).style.display = 'none'; (document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position; @@ -262,6 +266,8 @@ export async function showCSSCalibration(cssId: any) { _calTestDeviceList = devices; const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement; testGroup.style.display = devices.length ? '' : 'none'; + const testSection = document.getElementById('calibration-test-setup-section') as HTMLElement | null; + if (testSection) testSection.style.display = ''; // Pre-select device: 1) LED count match, 2) last remembered, 3) first if (devices.length) { diff --git a/server/src/ledgrab/static/js/features/dashboard-layout.ts b/server/src/ledgrab/static/js/features/dashboard-layout.ts index e5c4812..b1c36a9 100644 --- a/server/src/ledgrab/static/js/features/dashboard-layout.ts +++ b/server/src/ledgrab/static/js/features/dashboard-layout.ts @@ -229,6 +229,114 @@ function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayo }; } +/** Structural deep-equal that ignores `undefined` properties and key order. + * Needed because a saved layout coming back from JSON.parse may omit + * optional fields (e.g. `colorOverride`) that the factory output also + * omits — but a naive `JSON.stringify` comparison can still differ if + * insertion order ever drifts. */ +function _deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') { + return false; + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!_deepEqual(a[i], b[i])) return false; + } + return true; + } + const ao = a as Record; + const bo = b as Record; + const aKeys = Object.keys(ao).filter(k => ao[k] !== undefined); + const bKeys = Object.keys(bo).filter(k => bo[k] !== undefined); + if (aKeys.length !== bKeys.length) return false; + for (const k of aKeys) { + if (!_deepEqual(ao[k], bo[k])) return false; + } + return true; +} + +/** Detect which preset (if any) the layout currently matches. Recomputed + * on every save/load so the "modified" hint is the truth, not a flag that + * drifts when a user edits then reverts a setting. + * + * Compares only the data-bearing fields — `presetActive` itself is ignored, + * since it's the value we're computing. */ +function _computeActivePreset(layout: DashboardLayoutV1): string | undefined { + for (const [name, factory] of Object.entries(PRESETS)) { + const p = factory(); + if ( + layout.version === p.version && + _deepEqual(layout.global, p.global) && + _deepEqual(layout.sections, p.sections) && + _deepEqual(layout.perfCells, p.perfCells) + ) { + return name; + } + } + return undefined; +} + +/** Diagnostic: list per-preset mismatches for the current layout. Exposed + * on `window.__dashboardLayoutDiff` so a user reporting "MODIFIED with no + * changes" can run it from DevTools and see exactly which field drifted. */ +function _diffAgainstPresets(layout: DashboardLayoutV1): Record { + const out: Record = {}; + for (const [name, factory] of Object.entries(PRESETS)) { + const p = factory(); + const diffs: string[] = []; + if (layout.version !== p.version) diffs.push(`version: ${layout.version} vs ${p.version}`); + const asRec = (o: object): Record => o as unknown as Record; + if (!_deepEqual(layout.global, p.global)) { + for (const k of Object.keys({ ...layout.global, ...p.global })) { + const lv = asRec(layout.global)[k]; + const pv = asRec(p.global)[k]; + if (!_deepEqual(lv, pv)) diffs.push(`global.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`); + } + } + if (!_deepEqual(layout.sections, p.sections)) { + const lKeys = layout.sections.map(s => s.key).join(','); + const pKeys = p.sections.map(s => s.key).join(','); + if (lKeys !== pKeys) diffs.push(`sections.order: [${lKeys}] vs [${pKeys}]`); + for (const ls of layout.sections) { + const ps = p.sections.find(x => x.key === ls.key); + if (!ps) { diffs.push(`sections.${ls.key}: extra`); continue; } + if (!_deepEqual(ls, ps)) { + for (const k of Object.keys({ ...ls, ...ps })) { + const lv = asRec(ls)[k]; + const pv = asRec(ps)[k]; + if (!_deepEqual(lv, pv)) diffs.push(`sections.${ls.key}.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`); + } + } + } + } + if (!_deepEqual(layout.perfCells, p.perfCells)) { + const lKeys = layout.perfCells.map(c => c.key).join(','); + const pKeys = p.perfCells.map(c => c.key).join(','); + if (lKeys !== pKeys) diffs.push(`perfCells.order: [${lKeys}] vs [${pKeys}]`); + for (const lc of layout.perfCells) { + const pc = p.perfCells.find(x => x.key === lc.key); + if (!pc) { diffs.push(`perfCells.${lc.key}: extra`); continue; } + if (!_deepEqual(lc, pc)) { + for (const k of Object.keys({ ...lc, ...pc })) { + const lv = asRec(lc)[k]; + const pv = asRec(pc)[k]; + if (!_deepEqual(lv, pv)) diffs.push(`perfCells.${lc.key}.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`); + } + } + } + } + out[name] = diffs; + } + return out; +} + +if (typeof window !== 'undefined') { + (window as unknown as Record).__dashboardLayoutDiff = () => _diffAgainstPresets(_current); +} + let _current: DashboardLayoutV1 = _clone(DEFAULT_LAYOUT, 'studio'); let _serverSyncedOnce = false; const _listeners = new Set<() => void>(); @@ -300,7 +408,7 @@ export async function syncDashboardLayoutFromServer(): Promise { /** Persist a layout. Updates in-memory state immediately, debounces * the network write, and notifies listeners synchronously. */ export function saveDashboardLayout(next: DashboardLayoutV1): void { - _current = _clone(next, next.presetActive); + _current = _clone(next, _computeActivePreset(next)); try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ } _notify(); if (_saveTimer) clearTimeout(_saveTimer); @@ -551,7 +659,7 @@ function _mergeWithDefaults(input: unknown): DashboardLayoutV1 { base.global = { ...base.global, ...obj.global }; } - if (typeof obj.presetActive === 'string') base.presetActive = obj.presetActive; + base.presetActive = _computeActivePreset(base); return base; } @@ -586,5 +694,6 @@ function _migrateFromLegacyKeys(): DashboardLayoutV1 { } catch { /* ignore */ } } + layout.presetActive = _computeActivePreset(layout); return layout; } diff --git a/server/src/ledgrab/static/js/features/integrations.ts b/server/src/ledgrab/static/js/features/integrations.ts index 758de7a..444ed46 100644 --- a/server/src/ledgrab/static/js/features/integrations.ts +++ b/server/src/ledgrab/static/js/features/integrations.ts @@ -175,7 +175,8 @@ function renderIntegrationsList() { initHASourceDelegation(container); initMQTTSourceDelegation(container); - // Render tree sidebar + // Render tree sidebar with tutorial trigger button + _integrationsTree.setExtraHtml(``); _integrationsTree.update(treeGroups, activeTab); _integrationsTree.observeSections('integrations-list', { 'weather-sources': 'weather', diff --git a/server/src/ledgrab/static/js/features/tutorials.ts b/server/src/ledgrab/static/js/features/tutorials.ts index 247399a..99b27f7 100644 --- a/server/src/ledgrab/static/js/features/tutorials.ts +++ b/server/src/ledgrab/static/js/features/tutorials.ts @@ -10,6 +10,8 @@ interface TutorialStep { textKey: string; position: string; global?: boolean; + /** Optional sub-tab to switch to before highlighting this step. */ + subTab?: string; } interface TutorialConfig { @@ -19,6 +21,13 @@ interface TutorialConfig { container: Element | null; resolveTarget: (step: TutorialStep) => Element | null; onClose?: (() => void) | null; + /** Called with `step.subTab` before each step. */ + switchSubTab?: (key: string) => void; + /** CSS selector for the element whose textContent is the current + * sub-tab label — shown as a breadcrumb in the tooltip header. */ + breadcrumbSelector?: string; + /** Called with the current step before resolving the target. */ + prepare?: (step: TutorialStep) => void; } const calibrationTutorialSteps: TutorialStep[] = [ @@ -43,6 +52,7 @@ const gettingStartedSteps: TutorialStep[] = [ { selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' }, { selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' }, { selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' }, + { selector: '#tab-btn-integrations', textKey: 'tour.integrations', position: 'bottom' }, { selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' }, { selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' }, { selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' }, @@ -56,36 +66,57 @@ const gettingStartedSteps: TutorialStep[] = [ const dashboardTutorialSteps: TutorialStep[] = [ { selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' }, + { selector: '[data-dashboard-section="targets"]', textKey: 'tour.dash.targets', position: 'bottom' }, { selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' }, { selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' }, - { selector: '[data-dashboard-section="automations"]', textKey: 'tour.dash.automations', position: 'bottom' } + { selector: '[data-dashboard-section="automations"]', textKey: 'tour.dash.automations', position: 'bottom' }, + { selector: '[data-dashboard-section="scenes"]', textKey: 'tour.dash.scenes', position: 'bottom' }, + { selector: '[data-dashboard-section="sync-clocks"]', textKey: 'tour.dash.sync_clocks', position: 'bottom' }, + { selector: '[data-dashboard-section="integrations"]', textKey: 'tour.dash.integrations', position: 'bottom' }, + // Customize panel walkthrough — panel is opened/closed via the + // tour's `prepare` hook based on whether the step targets it. + { selector: '[onclick*="openDashboardCustomize"]', textKey: 'tour.dash.customize_btn', position: 'bottom' }, + { selector: '#dashboard-customize-panel .dash-cust-header', textKey: 'tour.dash.customize_panel', position: 'left' }, + { selector: '#dashboard-customize-panel .dash-cust-section:nth-of-type(1)', textKey: 'tour.dash.customize_presets', position: 'left' }, + { selector: '#dashboard-customize-panel .dash-cust-section:nth-of-type(2)', textKey: 'tour.dash.customize_global', position: 'left' }, + { selector: '#dashboard-customize-panel .dash-cust-section:nth-of-type(3)', textKey: 'tour.dash.customize_sections', position: 'left' }, + { selector: '#dashboard-customize-panel .dash-cust-section:nth-of-type(4)', textKey: 'tour.dash.customize_perf_cells', position: 'left' } ]; const targetsTutorialSteps: TutorialStep[] = [ - { selector: '[data-tree-group="led_group"]', textKey: 'tour.tgt.led_tab', position: 'right' }, - { selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' }, - { selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' }, - { selector: '[data-tree-group="kc_group"]', textKey: 'tour.tgt.kc_tab', position: 'right' } + { selector: '#targets-tree-nav .tree-dd-trigger', textKey: 'tour.tgt.nav', position: 'right' }, + { selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom', subTab: 'led-devices' }, + { selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom', subTab: 'led-targets' }, + { selector: '[data-card-section="ha-light-targets"]', textKey: 'tour.tgt.ha_light_targets', position: 'bottom', subTab: 'ha-light-targets' } ]; const sourcesTourSteps: TutorialStep[] = [ - { selector: '#streams-tree-nav [data-tree-leaf="raw"]', textKey: 'tour.src.raw', position: 'right' }, - { selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' }, - { selector: '#streams-tree-nav [data-tree-leaf="static_image"]', textKey: 'tour.src.static', position: 'right' }, - { selector: '#streams-tree-nav [data-tree-leaf="processed"]', textKey: 'tour.src.processed', position: 'right' }, - { selector: '#streams-tree-nav [data-tree-leaf="color_strip"]', textKey: 'tour.src.color_strip', position: 'right' }, - { selector: '#streams-tree-nav [data-tree-leaf="audio"]', textKey: 'tour.src.audio', position: 'right' }, - { selector: '#streams-tree-nav [data-tree-leaf="value"]', textKey: 'tour.src.value', position: 'right' }, - { selector: '#streams-tree-nav [data-tree-leaf="sync"]', textKey: 'tour.src.sync', position: 'right' } + { selector: '#streams-tree-nav .tree-dd-trigger', textKey: 'tour.src.nav', position: 'right' }, + { selector: '[data-card-section="raw-streams"]', textKey: 'tour.src.raw', position: 'bottom', subTab: 'raw' }, + { selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom', subTab: 'raw_templates' }, + { selector: '[data-card-section="static-streams"]', textKey: 'tour.src.static', position: 'bottom', subTab: 'static_image' }, + { selector: '[data-card-section="proc-streams"]', textKey: 'tour.src.processed', position: 'bottom', subTab: 'processed' }, + { selector: '[data-card-section="color-strips"]', textKey: 'tour.src.color_strip', position: 'bottom', subTab: 'color_strip' }, + { selector: '[data-card-section="audio-capture"]', textKey: 'tour.src.audio', position: 'bottom', subTab: 'audio_capture' }, + { selector: '[data-card-section="value-sources"]', textKey: 'tour.src.value', position: 'bottom', subTab: 'value' }, + { selector: '[data-card-section="sync-clocks"]', textKey: 'tour.src.sync', position: 'bottom', subTab: 'sync' } ]; const automationsTutorialSteps: TutorialStep[] = [ - { selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', position: 'bottom' }, - { selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom' }, - { selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom' }, - { selector: '[data-card-section="scenes"]', textKey: 'tour.auto.scenes_list', position: 'bottom' }, - { selector: '[data-cs-add="scenes"]', textKey: 'tour.auto.scenes_add', position: 'bottom' }, - { selector: '.card[data-scene-id]', textKey: 'tour.auto.scenes_card', position: 'bottom' }, + { selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', position: 'bottom', subTab: 'automations' }, + { selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom', subTab: 'automations' }, + { selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom', subTab: 'automations' }, + { selector: '[data-card-section="scenes"]', textKey: 'tour.auto.scenes_list', position: 'bottom', subTab: 'scenes' }, + { selector: '[data-cs-add="scenes"]', textKey: 'tour.auto.scenes_add', position: 'bottom', subTab: 'scenes' }, + { selector: '.card[data-scene-id]', textKey: 'tour.auto.scenes_card', position: 'bottom', subTab: 'scenes' }, +]; + +const integrationsTutorialSteps: TutorialStep[] = [ + { selector: '#integrations-tree-nav .tree-dd-trigger', textKey: 'tour.int.nav', position: 'right' }, + { selector: '[data-card-section="weather-sources"]', textKey: 'tour.int.weather', position: 'bottom', subTab: 'weather' }, + { selector: '[data-card-section="ha-sources"]', textKey: 'tour.int.home_assistant', position: 'bottom', subTab: 'home_assistant' }, + { selector: '[data-card-section="mqtt-sources"]', textKey: 'tour.int.mqtt', position: 'bottom', subTab: 'mqtt' }, + { selector: '[data-card-section="game-integrations"]', textKey: 'tour.int.game', position: 'bottom', subTab: 'game' } ]; const _fixedResolve = (step: TutorialStep): Element | null => { @@ -97,13 +128,12 @@ const _fixedResolve = (step: TutorialStep): Element | null => { }; const deviceTutorialSteps: TutorialStep[] = [ - { selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' }, - { selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' }, - { selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' }, - { selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' }, - { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' }, - { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' }, - { selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' } + { selector: '.mod-head', textKey: 'device.tip.identity', position: 'bottom' }, + { selector: '.mod-meta', textKey: 'device.tip.metadata', position: 'bottom' }, + { selector: '.mod-fader', textKey: 'device.tip.brightness', position: 'bottom' }, + { selector: '.mod-foot .mod-btn[onclick*="pingDevice"]', textKey: 'device.tip.ping', position: 'top' }, + { selector: '.mod-foot .mod-btn[onclick*="showSettings"]', textKey: 'device.tip.settings', position: 'top' }, + { selector: '.mod-menu-wrap', textKey: 'device.tip.menu', position: 'left' } ]; export function startTutorial(config: TutorialConfig): void { @@ -120,7 +150,10 @@ export function startTutorial(config: TutorialConfig): void { step: 0, resolveTarget: config.resolveTarget, container: config.container, - onClose: config.onClose || null + onClose: config.onClose || null, + switchSubTab: config.switchSubTab || null, + breadcrumbSelector: config.breadcrumbSelector || null, + prepare: config.prepare || null }); // Hide tooltip and ring until first step positions them (prevents flash at 0,0) @@ -186,22 +219,47 @@ export function startGettingStartedTutorial(): void { } export function startDashboardTutorial(): void { + const isCustomizeStep = (step: TutorialStep): boolean => + step.selector.includes('#dashboard-customize-panel'); + + const callWindow = (fnName: string): void => { + const fn = (window as any)[fnName]; + if (typeof fn === 'function') fn(); + }; + startTutorial({ steps: dashboardTutorialSteps, overlayId: 'getting-started-overlay', mode: 'fixed', container: null, - resolveTarget: _fixedResolve + resolveTarget: _fixedResolve, + prepare: (step: TutorialStep): void => { + // Open the customize panel for steps inside it; close it + // when the tour moves back out so dashboard sections aren't + // covered. + if (isCustomizeStep(step)) callWindow('openDashboardCustomize'); + else callWindow('closeDashboardCustomize'); + }, + onClose: (): void => callWindow('closeDashboardCustomize') }); } +function _windowSwitch(fnName: string): (key: string) => void { + return (key: string): void => { + const fn = (window as any)[fnName]; + if (typeof fn === 'function') fn(key); + }; +} + export function startTargetsTutorial(): void { startTutorial({ steps: targetsTutorialSteps, overlayId: 'getting-started-overlay', mode: 'fixed', container: null, - resolveTarget: _fixedResolve + resolveTarget: _fixedResolve, + switchSubTab: _windowSwitch('switchTargetSubTab'), + breadcrumbSelector: '#targets-tree-nav .tree-dd-trigger-title' }); } @@ -211,7 +269,9 @@ export function startSourcesTutorial(): void { overlayId: 'getting-started-overlay', mode: 'fixed', container: null, - resolveTarget: _fixedResolve + resolveTarget: _fixedResolve, + switchSubTab: _windowSwitch('switchStreamTab'), + breadcrumbSelector: '#streams-tree-nav .tree-dd-trigger-title' }); } @@ -221,7 +281,21 @@ export function startAutomationsTutorial(): void { overlayId: 'getting-started-overlay', mode: 'fixed', container: null, - resolveTarget: _fixedResolve + resolveTarget: _fixedResolve, + switchSubTab: _windowSwitch('switchAutomationTab'), + breadcrumbSelector: '#automations-tree-nav .tree-dd-trigger-title' + }); +} + +export function startIntegrationsTutorial(): void { + startTutorial({ + steps: integrationsTutorialSteps, + overlayId: 'getting-started-overlay', + mode: 'fixed', + container: null, + resolveTarget: _fixedResolve, + switchSubTab: _windowSwitch('switchIntegrationTab'), + breadcrumbSelector: '#integrations-tree-nav .tree-dd-trigger-title' }); } @@ -254,6 +328,84 @@ export function tutorialPrev(): void { } } +function _renderPips(overlay: HTMLElement, total: number, current: number): void { + const container = overlay.querySelector('.tutorial-pips'); + if (!container) return; + if (container.children.length !== total) { + container.innerHTML = ''; + for (let i = 0; i < total; i++) { + const pip = document.createElement('span'); + pip.className = 'tutorial-pip'; + container.appendChild(pip); + } + } + Array.from(container.children).forEach((pip, i) => { + pip.classList.toggle('done', i < current); + pip.classList.toggle('active', i === current); + }); +} + +function _drawCable( + overlay: HTMLElement, + rx: number, ry: number, rw: number, rh: number, + tooltip: HTMLElement, + actualPosition: string +): void { + const cable = overlay.querySelector('.tutorial-cable'); + if (!cable) return; + const line = cable.querySelector('line') as SVGLineElement | null; + if (!line) return; + + // Tooltip rect is viewport-relative. Ring rect (rx, ry, rw, rh) is in + // the same coord space as the SVG: viewport for fixed overlays, + // container-relative for absolute (modal) overlays. Translate the + // tooltip rect so both endpoints share that space. + const tt = tooltip.getBoundingClientRect(); + const isFixed = activeTutorial?.mode === 'fixed'; + let ttLeft = tt.left; + let ttTop = tt.top; + if (!isFixed && activeTutorial?.container) { + const cRect = activeTutorial.container.getBoundingClientRect(); + ttLeft -= cRect.left; + ttTop -= cRect.top; + } + const ttRight = ttLeft + tt.width; + const ttBottom = ttTop + tt.height; + + let x1: number, y1: number, x2: number, y2: number; + switch (actualPosition) { + case 'top': + x1 = rx + rw / 2; y1 = ry; + x2 = ttLeft + tt.width / 2; y2 = ttBottom; + break; + case 'left': + x1 = rx; y1 = ry + rh / 2; + x2 = ttRight; y2 = ttTop + tt.height / 2; + break; + case 'right': + x1 = rx + rw; y1 = ry + rh / 2; + x2 = ttLeft; y2 = ttTop + tt.height / 2; + break; + case 'bottom': + default: + x1 = rx + rw / 2; y1 = ry + rh; + x2 = ttLeft + tt.width / 2; y2 = ttTop; + break; + } + + line.setAttribute('x1', String(x1)); + line.setAttribute('y1', String(y1)); + line.setAttribute('x2', String(x2)); + line.setAttribute('y2', String(y2)); +} + +function _flashCornerLock(overlay: HTMLElement): void { + overlay.classList.remove('step-changed'); + // Force reflow so removing+re-adding the class re-triggers the animation. + void (overlay as HTMLElement).offsetWidth; + overlay.classList.add('step-changed'); +} + function _positionSpotlight(target: Element, overlay: HTMLElement, step: TutorialStep, index: number, isFixed: boolean): void { const targetRect = target.getBoundingClientRect(); const pad = 6; @@ -272,6 +424,8 @@ function _positionSpotlight(target: Element, overlay: HTMLElement, step: Tutoria h = targetRect.height + pad * 2; } + const isV2 = overlay.classList.contains('tutorial-v2'); + const backdrop = overlay.querySelector('.tutorial-backdrop') as HTMLElement; if (backdrop) { backdrop.style.clipPath = `polygon( @@ -294,18 +448,47 @@ function _positionSpotlight(target: Element, overlay: HTMLElement, step: Tutoria const textEl = overlay.querySelector('.tutorial-tooltip-text'); const counterEl = overlay.querySelector('.tutorial-step-counter'); if (textEl) textEl.textContent = t(step.textKey); - if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial!.steps.length}`; + const total = activeTutorial!.steps.length; + if (counterEl) { + const pad2 = (n: number) => String(n).padStart(2, '0'); + counterEl.textContent = isV2 + ? `${pad2(index + 1)} / ${pad2(total)}` + : `${index + 1} / ${total}`; + } + + const breadcrumbEl = overlay.querySelector('.tutorial-tooltip-breadcrumb') as HTMLElement | null; + if (breadcrumbEl) { + const sel = activeTutorial!.breadcrumbSelector; + const labelSrc = sel ? document.querySelector(sel) : null; + const label = (labelSrc?.textContent || '').trim(); + breadcrumbEl.textContent = label; + breadcrumbEl.classList.toggle('is-visible', label.length > 0); + } const prevBtn = overlay.querySelector('.tutorial-prev-btn') as HTMLButtonElement; - const nextBtn = overlay.querySelector('.tutorial-next-btn'); + const nextBtn = overlay.querySelector('.tutorial-next-btn') as HTMLButtonElement | null; if (prevBtn) prevBtn.disabled = (index === 0); - if (nextBtn) nextBtn.textContent = (index === activeTutorial!.steps.length - 1) ? '\u2713' : '\u2192'; + if (nextBtn) { + const isLast = index === total - 1; + if (isV2) { + nextBtn.innerHTML = isLast ? 'FINISH \u2713' : 'NEXT \u25b6'; + } else { + nextBtn.textContent = isLast ? '\u2713' : '\u2192'; + } + } + let actualPosition = step.position; if (tooltip) { - positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed); + actualPosition = positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed); tooltip.style.visibility = ''; } if (ring) ring.style.visibility = ''; + + if (isV2) { + _renderPips(overlay, total, index); + if (tooltip) _drawCable(overlay, x, y, w, h, tooltip, actualPosition); + _flashCornerLock(overlay); + } } /** Wait for scroll to settle (position stops changing). */ @@ -337,6 +520,16 @@ function showTutorialStep(index: number, direction: number = 1): void { (el as HTMLElement).style.zIndex = ''; }); + // Reveal panels that live behind a sub-tab before resolving the target. + if (step.subTab && activeTutorial.switchSubTab) { + activeTutorial.switchSubTab(step.subTab); + } + + // Tour-level hook: open/close auxiliary UI (side panels, popups). + if (activeTutorial.prepare) { + activeTutorial.prepare(step); + } + const target = activeTutorial.resolveTarget(step); if (!target) { // Auto-skip hidden/missing targets in the current direction @@ -368,9 +561,10 @@ function showTutorialStep(index: number, direction: number = 1): void { } } -function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, sw: number, sh: number, preferred: string, isFixed: boolean): void { +function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, sw: number, sh: number, preferred: string, isFixed: boolean): string { const gap = 12; - const tooltipW = 260; + // v2 tooltip is wider; measure the live width instead of hardcoding. + const tooltipW = tooltip.offsetWidth || 260; tooltip.setAttribute('style', 'left:-9999px;top:-9999px'); const tooltipH = tooltip.offsetHeight || 150; @@ -381,6 +575,7 @@ function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, s right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 } }; + let chosen = preferred; let pos = positions[preferred] || positions.bottom; const cW = isFixed ? window.innerWidth : activeTutorial!.container!.clientWidth; @@ -388,9 +583,11 @@ function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, s if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) { const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; - const alt = positions[opposite[preferred]]; + const altKey = opposite[preferred]; + const alt = positions[altKey]; if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) { pos = alt; + chosen = altKey; } } @@ -398,6 +595,7 @@ function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, s pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y)); tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`); + return chosen; } function handleTutorialKey(e: KeyboardEvent): void { diff --git a/server/src/ledgrab/static/js/global.d.ts b/server/src/ledgrab/static/js/global.d.ts index c556127..1359449 100644 --- a/server/src/ledgrab/static/js/global.d.ts +++ b/server/src/ledgrab/static/js/global.d.ts @@ -68,6 +68,7 @@ interface Window { startTargetsTutorial: (...args: any[]) => any; startSourcesTutorial: (...args: any[]) => any; startAutomationsTutorial: (...args: any[]) => any; + startIntegrationsTutorial: (...args: any[]) => any; closeTutorial: (...args: any[]) => any; tutorialNext: (...args: any[]) => any; tutorialPrev: (...args: any[]) => any; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index bfbb886..bc88519 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -351,14 +351,13 @@ "device.last_seen.hours": "%dh ago", "device.last_seen.days": "%dd ago", "device.tutorial.start": "Start tutorial", - "device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device", - "device.tip.brightness": "Slide to adjust device brightness", + "device.tip.identity": "Device identity — name, type badge, and health indicator showing online/offline status.", + "device.tip.metadata": "Device address and firmware version. Click the URL to open the device's built-in web UI.", + "device.tip.brightness": "Drag to adjust device brightness. Changes are sent to the device immediately.", "device.brightness": "Bright", - "device.tip.start": "Start or stop screen capture processing", - "device.tip.settings": "Configure general device settings (name, URL, health check)", - "device.tip.capture_settings": "Configure capture settings (display, capture template)", - "device.tip.calibrate": "Calibrate LED positions, direction, and coverage", - "device.tip.webui": "Open the device's built-in web interface for advanced configuration", + "device.tip.ping": "Ping the device to refresh online status and latency.", + "device.tip.settings": "Open device settings — configure name, URL, capabilities, and health checks.", + "device.tip.menu": "More actions — duplicate, hide, or delete this device.", "device.tip.add": "Click here to add a new LED device", "settings.title": "Settings", "settings.tab.general": "General", @@ -377,6 +376,39 @@ "settings.section.connection": "Connection", "settings.section.hardware": "Hardware", "settings.section.behavior": "Behavior", + "settings.section.provider": "Provider", + "settings.section.refresh": "Refresh", + "settings.section.filters": "Filters", + "settings.section.routing": "Routing", + "settings.section.output": "Output", + "settings.section.filtering": "Filtering", + "settings.section.broker": "Broker", + "settings.section.protocol": "Protocol", + "settings.section.adapter": "Adapter", + "settings.section.mappings": "Mappings", + "settings.section.diagnostics": "Diagnostics", + "settings.section.source": "Source", + "settings.section.engine": "Engine", + "settings.section.timing": "Timing", + "settings.section.gradient": "Gradient", + "settings.section.layout": "Layout", + "settings.section.strip": "Strip", + "settings.section.type": "Type", + "settings.section.notes": "Notes", + "settings.section.file": "File", + "settings.section.auth": "Auth", + "settings.section.configure": "Configure", + "settings.section.restart": "Restart", + "settings.section.loopback": "Loopback", + "settings.section.test_setup": "Test Setup", + "settings.section.offsets": "Offsets", + "settings.section.line_properties": "Line Properties", + "settings.section.test": "Test", + "settings.section.preview": "Preview", + "settings.section.controls": "Controls", + "settings.section.history": "History", + "ha_source.use_ssl.hint": "Enable for HTTPS / wss connections to Home Assistant", + "game_integration.enabled.hint": "Disabled integrations stop polling and emitting events", "settings.capture.title": "Capture Settings", "settings.capture.saved": "Capture settings updated", "settings.capture.failed": "Failed to save capture settings", @@ -413,6 +445,7 @@ "tour.dashboard": "Dashboard — live overview of running targets, automations, and device health at a glance.", "tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.", "tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.", + "tour.integrations": "Integrations — connect external services: Weather, Home Assistant, MQTT, and Game integrations.", "tour.graph": "Graph — visual overview of all entities and their connections. Drag ports to connect, right-click edges to disconnect.", "tour.automations": "Automations — automate scene switching with time, audio, or value rules.", "tour.settings": "Settings — backup and restore configuration, manage auto-backups.", @@ -423,21 +456,32 @@ "tour.language": "Language — choose your preferred interface language.", "tour.restart": "Restart tutorial", "tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.", + "tour.dash.targets": "Channels — all your targets in one place, grouped into running and stopped subsections.", "tour.dash.running": "Running targets — live streaming metrics and quick stop control.", "tour.dash.stopped": "Stopped targets — ready to start with one click.", "tour.dash.automations": "Automations — active automation status and quick enable/disable toggle.", - "tour.tgt.led_tab": "LED tab — standard LED strip targets with device and color strip configuration.", + "tour.dash.scenes": "Scene Presets — saved system snapshots you can apply with a single click.", + "tour.dash.sync_clocks": "Sync Clocks — shared timers that synchronize animations across sources.", + "tour.dash.integrations": "Integrations — connection status for Weather, Home Assistant, and MQTT sources.", + "tour.dash.customize_btn": "Customize button — opens a side panel where you can reorder sections, toggle visibility, and tune the dashboard layout.", + "tour.dash.customize_panel": "Dashboard Customize — your control center for layout. Changes apply live; close with Esc or the × button.", + "tour.dash.customize_presets": "Presets — apply curated layouts (compact, focused, full) in one click. Switching to a preset overrides any manual tweaks.", + "tour.dash.customize_global": "Global settings — content width, animation level, performance mode (system/app/both), and the sample window for charts.", + "tour.dash.customize_sections": "Sections — drag to reorder; click the eye to hide; click the chevron to start collapsed by default. Each section can also be tagged dense/compact/comfortable.", + "tour.dash.customize_perf_cells": "Performance cells — choose which metric tiles to show in the Performance section, configure their span, sample window, and Y-axis scale.", + "tour.tgt.nav": "Use this dropdown to switch between target types: LED devices, LED targets, and Home Assistant lights.", "tour.tgt.devices": "Devices — your LED controllers discovered on the network.", "tour.tgt.css": "Color Strips — define how screen regions map to LED segments.", "tour.tgt.targets": "LED Targets — combine a device, color strip, and capture source for streaming.", - "tour.tgt.kc_tab": "Key Colors — alternative target type using color-matching instead of pixel mapping.", + "tour.tgt.ha_light_targets": "HA Light Targets — pick a Home Assistant light entity and a color/value source to drive it.", + "tour.src.nav": "Use this dropdown to switch between source types — pictures, color strips, audio, value, sync, and assets.", "tour.src.raw": "Raw — live screen capture sources from your displays.", "tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).", "tour.src.static": "Static Image — test your setup with image files instead of live capture.", "tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.", "tour.src.color_strip": "Color Strips — define how screen regions map to LED segments.", - "tour.src.audio": "Audio — analyze microphone or system audio for reactive LED effects.", - "tour.src.value": "Value — numeric data sources used as rules in automations.", + "tour.src.audio": "Audio — capture microphone/system audio and process it through templates for reactive effects.", + "tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, gradients, or schedules. Used to animate effects, modulate color strips, and trigger automations.", "tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.", "tour.auto.list": "Automations — automate scene activation based on time, audio, or value rules.", "tour.auto.add": "Click + to create a new automation with rules and a scene to activate.", @@ -445,6 +489,11 @@ "tour.auto.scenes_list": "Scenes — saved system states that automations can activate or you can apply manually.", "tour.auto.scenes_add": "Click + to capture the current system state as a new scene preset.", "tour.auto.scenes_card": "Each scene card shows target/device counts. Click to edit, recapture, or activate.", + "tour.int.nav": "Use this dropdown to switch between integration types: Weather, Home Assistant, MQTT, and Game integrations.", + "tour.int.weather": "Weather — pull live weather data (temperature, conditions, forecast) into value sources for environmental effects.", + "tour.int.home_assistant": "Home Assistant — connect to your HA instance. Lights, sensors, and entities become value sources and target endpoints.", + "tour.int.mqtt": "MQTT — subscribe to broker topics to feed external sensor data, automations, or remote triggers into LedGrab.", + "tour.int.game": "Game — react to in-game events (Minecraft, Hammer of God, etc.) by triggering scenes and color effects from game state.", "calibration.tutorial.start": "Start tutorial", "calibration.overlay_toggle": "Overlay", "calibration.start_position": "Starting Position:", @@ -880,6 +929,9 @@ "automations.add": "Add Automation", "automations.edit": "Edit Automation", "automations.delete.confirm": "Delete automation \"{name}\"?", + "automations.section.triggers": "Triggers", + "automations.section.action": "Action", + "automations.section.deactivation": "Deactivation", "automations.name": "Name:", "automations.name.hint": "A descriptive name for this automation", "automations.name.placeholder": "My Automation", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index fe9141a..ed1c308 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -355,14 +355,13 @@ "device.last_seen.hours": "%d ч назад", "device.last_seen.days": "%d д назад", "device.tutorial.start": "Начать обучение", - "device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически", - "device.tip.brightness": "Перетащите для регулировки яркости", + "device.tip.identity": "Идентификация устройства — имя, тип и индикатор онлайн/офлайн.", + "device.tip.metadata": "Адрес устройства и версия прошивки. Кликните по URL, чтобы открыть веб-интерфейс устройства.", + "device.tip.brightness": "Перетащите для регулировки яркости. Изменения применяются мгновенно.", "device.brightness": "Яркость", - "device.tip.start": "Запуск или остановка захвата экрана", - "device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)", - "device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)", - "device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия", - "device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки", + "device.tip.ping": "Пинг устройства — обновляет статус и измеряет задержку.", + "device.tip.settings": "Открыть настройки устройства — имя, URL, возможности и проверки соединения.", + "device.tip.menu": "Дополнительные действия — дублировать, скрыть или удалить устройство.", "device.tip.add": "Нажмите, чтобы добавить новое LED устройство", "settings.title": "Настройки", "settings.tab.general": "Основные", @@ -381,6 +380,39 @@ "settings.section.connection": "Подключение", "settings.section.hardware": "Оборудование", "settings.section.behavior": "Поведение", + "settings.section.provider": "Провайдер", + "settings.section.refresh": "Обновление", + "settings.section.filters": "Фильтры", + "settings.section.routing": "Маршрутизация", + "settings.section.output": "Вывод", + "settings.section.filtering": "Фильтрация", + "settings.section.broker": "Брокер", + "settings.section.protocol": "Протокол", + "settings.section.adapter": "Адаптер", + "settings.section.mappings": "Сопоставления", + "settings.section.diagnostics": "Диагностика", + "settings.section.source": "Источник", + "settings.section.engine": "Движок", + "settings.section.timing": "Тайминг", + "settings.section.gradient": "Градиент", + "settings.section.layout": "Раскладка", + "settings.section.strip": "Лента", + "settings.section.type": "Тип", + "settings.section.notes": "Заметки", + "settings.section.file": "Файл", + "settings.section.auth": "Авторизация", + "settings.section.configure": "Настройка", + "settings.section.restart": "Перезапуск", + "settings.section.loopback": "Локальный доступ", + "settings.section.test_setup": "Параметры теста", + "settings.section.offsets": "Смещения", + "settings.section.line_properties": "Параметры линии", + "settings.section.test": "Тест", + "settings.section.preview": "Превью", + "settings.section.controls": "Управление", + "settings.section.history": "История", + "ha_source.use_ssl.hint": "Включите для HTTPS / wss соединений с Home Assistant", + "game_integration.enabled.hint": "Отключённые интеграции перестают опрашивать и генерировать события", "settings.capture.title": "Настройки Захвата", "settings.capture.saved": "Настройки захвата обновлены", "settings.capture.failed": "Не удалось сохранить настройки захвата", @@ -417,6 +449,7 @@ "tour.dashboard": "Дашборд — обзор запущенных целей, автоматизаций и состояния устройств.", "tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.", "tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.", + "tour.integrations": "Интеграции — подключайте внешние сервисы: погоду, Home Assistant, MQTT и игровые интеграции.", "tour.graph": "Граф — визуальный обзор всех сущностей и их связей. Перетаскивайте порты для соединения, правый клик по связям для отключения.", "tour.automations": "Автоматизации — автоматизируйте переключение сцен по расписанию, звуку или значениям.", "tour.settings": "Настройки — резервное копирование и восстановление конфигурации.", @@ -427,21 +460,32 @@ "tour.language": "Язык — выберите предпочитаемый язык интерфейса.", "tour.restart": "Запустить тур заново", "tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.", + "tour.dash.targets": "Каналы — все ваши цели в одном месте, разделённые на запущенные и остановленные.", "tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.", "tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.", "tour.dash.automations": "Автоматизации — статус активных автоматизаций и быстрое включение/выключение.", - "tour.tgt.led_tab": "LED — стандартные LED-цели с настройкой устройств и цветовых полос.", + "tour.dash.scenes": "Пресеты сцен — сохранённые состояния системы, применяются одним кликом.", + "tour.dash.sync_clocks": "Синхро-часы — общие таймеры для синхронизации анимаций между источниками.", + "tour.dash.integrations": "Интеграции — статус подключения для Weather, Home Assistant и MQTT.", + "tour.dash.customize_btn": "Кнопка «Настроить» — открывает боковую панель, где можно изменить порядок секций, скрыть их или подстроить раскладку дашборда.", + "tour.dash.customize_panel": "Настройка дашборда — центр управления раскладкой. Изменения применяются мгновенно; закрытие — Esc или кнопка ×.", + "tour.dash.customize_presets": "Пресеты — применяйте готовые раскладки (компактная, фокус, полная) одним кликом. Выбор пресета сбрасывает ручные настройки.", + "tour.dash.customize_global": "Глобальные настройки — ширина контента, уровень анимаций, режим производительности (system/app/both) и окно выборки графиков.", + "tour.dash.customize_sections": "Секции — перетаскивайте для смены порядка; «глаз» скрывает; «шеврон» делает секцию свёрнутой по умолчанию. Также для каждой секции можно выбрать плотность.", + "tour.dash.customize_perf_cells": "Ячейки производительности — выбирайте, какие метрики отображать в секции Performance, настраивайте ширину, окно выборки и шкалу Y.", + "tour.tgt.nav": "Используйте этот выпадающий список для переключения между типами целей: LED-устройства, LED-цели и лампы Home Assistant.", "tour.tgt.devices": "Устройства — ваши LED-контроллеры, найденные в сети.", "tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.", "tour.tgt.targets": "LED-цели — объедините устройство, цветовую полосу и источник захвата для стриминга.", - "tour.tgt.kc_tab": "Key Colors — альтернативный тип цели с подбором цветов вместо пиксельного маппинга.", + "tour.tgt.ha_light_targets": "Цели HA-ламп — выберите лампу Home Assistant и источник цвета/значения для управления.", + "tour.src.nav": "Используйте этот выпадающий список для переключения между типами источников — изображения, цветовые полосы, аудио, значения, синхронизация, ассеты.", "tour.src.raw": "Raw — источники захвата экрана с ваших дисплеев.", "tour.src.templates": "Шаблоны захвата — переиспользуемые конфигурации (разрешение, FPS, обрезка).", "tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.", "tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.", "tour.src.color_strip": "Цветовые полосы — определяют, как области экрана сопоставляются с LED-сегментами.", - "tour.src.audio": "Аудио — анализ микрофона или системного звука для реактивных LED-эффектов.", - "tour.src.value": "Значения — числовые источники данных для условий автоматизаций.", + "tour.src.audio": "Аудио — захват микрофона или системного звука и обработка через шаблоны для реактивных эффектов.", + "tour.src.value": "Источники значений — динамические числа или цвета, управляемые временем суток, аудио, системными метриками, сущностями Home Assistant, градиентами или расписаниями. Используются для анимации эффектов, модуляции цветовых полос и триггеров автоматизаций.", "tour.src.sync": "Синхро-часы — общие таймеры для синхронизации анимаций между несколькими источниками.", "tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.", "tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.", @@ -449,6 +493,11 @@ "tour.auto.scenes_list": "Сцены — сохранённые состояния системы, которые автоматизации могут активировать или вы можете применить вручную.", "tour.auto.scenes_add": "Нажмите + для захвата текущего состояния системы как нового пресета сцены.", "tour.auto.scenes_card": "Каждая карточка сцены показывает количество целей/устройств. Нажмите для редактирования, перезахвата или активации.", + "tour.int.nav": "Используйте этот выпадающий список для переключения между типами интеграций: погода, Home Assistant, MQTT и игровые интеграции.", + "tour.int.weather": "Погода — получайте данные о погоде (температура, условия, прогноз) в виде источников значений для эффектов от окружающей среды.", + "tour.int.home_assistant": "Home Assistant — подключение к экземпляру HA. Лампы, датчики и сущности становятся источниками значений и целями вывода.", + "tour.int.mqtt": "MQTT — подписывайтесь на топики брокера для передачи данных внешних датчиков, автоматизаций или удалённых триггеров в LedGrab.", + "tour.int.game": "Игры — реакция на игровые события (Minecraft, Hammer of God и др.) с активацией сцен и цветовых эффектов на основе состояния игры.", "calibration.tutorial.start": "Начать обучение", "calibration.overlay_toggle": "Оверлей", "calibration.start_position": "Начальная Позиция:", @@ -861,6 +910,9 @@ "automations.add": "Добавить автоматизацию", "automations.edit": "Редактировать автоматизацию", "automations.delete.confirm": "Удалить автоматизацию \"{name}\"?", + "automations.section.triggers": "Триггеры", + "automations.section.action": "Действие", + "automations.section.deactivation": "Деактивация", "automations.name": "Название:", "automations.name.hint": "Описательное имя для автоматизации", "automations.name.placeholder": "Моя автоматизация", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 2abd9bc..34a8e11 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -355,14 +355,13 @@ "device.last_seen.hours": "%d小时前", "device.last_seen.days": "%d天前", "device.tutorial.start": "开始教程", - "device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测", - "device.tip.brightness": "滑动调节设备亮度", + "device.tip.identity": "设备标识 — 名称、类型徽章和在线/离线状态指示。", + "device.tip.metadata": "设备地址和固件版本。点击 URL 可打开设备的内置 Web 界面。", + "device.tip.brightness": "拖动调节设备亮度。更改将立即发送到设备。", "device.brightness": "亮度", - "device.tip.start": "启动或停止屏幕采集处理", - "device.tip.settings": "配置设备常规设置(名称、地址、健康检查)", - "device.tip.capture_settings": "配置采集设置(显示器、采集模板)", - "device.tip.calibrate": "校准 LED 位置、方向和覆盖范围", - "device.tip.webui": "打开设备内置的 Web 界面进行高级配置", + "device.tip.ping": "Ping 设备 — 刷新在线状态并测量延迟。", + "device.tip.settings": "打开设备设置 — 配置名称、地址、能力和健康检查。", + "device.tip.menu": "更多操作 — 复制、隐藏或删除此设备。", "device.tip.add": "点击此处添加新的 LED 设备", "settings.title": "设置", "settings.tab.general": "常规", @@ -381,6 +380,39 @@ "settings.section.connection": "连接", "settings.section.hardware": "硬件", "settings.section.behavior": "行为", + "settings.section.provider": "提供商", + "settings.section.refresh": "刷新", + "settings.section.filters": "过滤器", + "settings.section.routing": "路由", + "settings.section.output": "输出", + "settings.section.filtering": "过滤", + "settings.section.broker": "代理服务器", + "settings.section.protocol": "协议", + "settings.section.adapter": "适配器", + "settings.section.mappings": "映射", + "settings.section.diagnostics": "诊断", + "settings.section.source": "源", + "settings.section.engine": "引擎", + "settings.section.timing": "时序", + "settings.section.gradient": "渐变", + "settings.section.layout": "布局", + "settings.section.strip": "灯带", + "settings.section.type": "类型", + "settings.section.notes": "备注", + "settings.section.file": "文件", + "settings.section.auth": "认证", + "settings.section.configure": "配置", + "settings.section.restart": "重启", + "settings.section.loopback": "本地访问", + "settings.section.test_setup": "测试设置", + "settings.section.offsets": "偏移", + "settings.section.line_properties": "线属性", + "settings.section.test": "测试", + "settings.section.preview": "预览", + "settings.section.controls": "控制", + "settings.section.history": "历史", + "ha_source.use_ssl.hint": "为 Home Assistant 启用 HTTPS / wss 连接", + "game_integration.enabled.hint": "已禁用的集成将停止轮询和发出事件", "settings.capture.title": "采集设置", "settings.capture.saved": "采集设置已更新", "settings.capture.failed": "保存采集设置失败", @@ -417,6 +449,7 @@ "tour.dashboard": "仪表盘 — 实时查看运行中的目标、自动化和设备状态。", "tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。", "tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。", + "tour.integrations": "集成 — 连接外部服务:天气、Home Assistant、MQTT 和游戏集成。", "tour.graph": "图表 — 所有实体及其连接的可视化概览。拖动端口进行连接,右键单击边线断开连接。", "tour.automations": "自动化 — 通过时间、音频或数值条件自动切换场景。", "tour.settings": "设置 — 备份和恢复配置,管理自动备份。", @@ -427,21 +460,32 @@ "tour.language": "语言 — 选择您偏好的界面语言。", "tour.restart": "重新开始导览", "tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。", + "tour.dash.targets": "通道 — 您的所有目标集中显示,按运行中和已停止分组。", "tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。", "tour.dash.stopped": "已停止的目标 — 一键启动。", "tour.dash.automations": "自动化 — 活动自动化状态和快速启用/禁用切换。", - "tour.tgt.led_tab": "LED 标签 — 标准 LED 灯带目标,包含设备和色带配置。", + "tour.dash.scenes": "场景预设 — 已保存的系统快照,一键应用。", + "tour.dash.sync_clocks": "同步时钟 — 跨多个源同步动画的共享计时器。", + "tour.dash.integrations": "集成 — Weather、Home Assistant 和 MQTT 源的连接状态。", + "tour.dash.customize_btn": "自定义按钮 — 打开侧面板,可重新排序、隐藏或调整仪表盘布局。", + "tour.dash.customize_panel": "自定义仪表盘 — 布局控制中心。更改即时生效;按 Esc 或 × 关闭。", + "tour.dash.customize_presets": "预设 — 一键应用精选布局(紧凑、聚焦、完整)。切换预设会覆盖任何手动调整。", + "tour.dash.customize_global": "全局设置 — 内容宽度、动画级别、性能模式(system/app/both)和图表采样窗口。", + "tour.dash.customize_sections": "部分 — 拖拽以重新排序;「眼睛」可隐藏;「箭头」可设为默认折叠。每个部分还可选择密度(密集/紧凑/舒适)。", + "tour.dash.customize_perf_cells": "性能单元 — 选择性能部分中显示哪些指标,配置跨度、采样窗口和 Y 轴比例。", + "tour.tgt.nav": "使用此下拉菜单在目标类型之间切换:LED 设备、LED 目标和 Home Assistant 灯。", "tour.tgt.devices": "设备 — 在网络中发现的 LED 控制器。", "tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。", "tour.tgt.targets": "LED 目标 — 将设备、色带和捕获源组合进行流式传输。", - "tour.tgt.kc_tab": "Key Colors — 使用颜色匹配代替像素映射的替代目标类型。", + "tour.tgt.ha_light_targets": "HA 灯目标 — 选择 Home Assistant 灯实体和颜色/数值源进行驱动。", + "tour.src.nav": "使用此下拉菜单在源类型之间切换 — 图片、色带、音频、数值、同步和资源。", "tour.src.raw": "原始 — 来自显示器的实时屏幕捕获源。", "tour.src.templates": "捕获模板 — 可复用的捕获配置(分辨率、FPS、裁剪)。", "tour.src.static": "静态图片 — 使用图片文件测试您的设置。", "tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。", "tour.src.color_strip": "色带 — 定义屏幕区域如何映射到 LED 段。", - "tour.src.audio": "音频 — 分析麦克风或系统音频以实现响应式 LED 效果。", - "tour.src.value": "数值 — 用于自动化条件的数字数据源。", + "tour.src.audio": "音频 — 捕获麦克风/系统音频,并通过模板进行处理以生成反应式效果。", + "tour.src.value": "数值源 — 由时间、音频、系统指标、Home Assistant 实体、渐变或日程驱动的动态数字或颜色。用于动画效果、调制色带和触发自动化。", "tour.src.sync": "同步时钟 — 在多个源之间同步动画的共享定时器。", "tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。", "tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。", @@ -449,6 +493,11 @@ "tour.auto.scenes_list": "场景 — 保存的系统状态,自动化可以激活或您可以手动应用。", "tour.auto.scenes_add": "点击 + 将当前系统状态捕获为新的场景预设。", "tour.auto.scenes_card": "每个场景卡片显示目标/设备数量。点击编辑、重新捕获或激活。", + "tour.int.nav": "使用此下拉菜单在集成类型之间切换:天气、Home Assistant、MQTT 和游戏集成。", + "tour.int.weather": "天气 — 获取实时天气数据(温度、状况、预报)作为环境效果的数值源。", + "tour.int.home_assistant": "Home Assistant — 连接到您的 HA 实例。灯、传感器和实体可作为数值源和目标端点。", + "tour.int.mqtt": "MQTT — 订阅代理主题,将外部传感器数据、自动化或远程触发器传入 LedGrab。", + "tour.int.game": "游戏 — 根据游戏内事件(Minecraft、Hammer of God 等)触发场景和色彩效果。", "calibration.tutorial.start": "开始教程", "calibration.overlay_toggle": "叠加层", "calibration.start_position": "起始位置:", @@ -861,6 +910,9 @@ "automations.add": "添加自动化", "automations.edit": "编辑自动化", "automations.delete.confirm": "删除自动化 \"{name}\"?", + "automations.section.triggers": "触发器", + "automations.section.action": "动作", + "automations.section.deactivation": "停用", "automations.name": "名称:", "automations.name.hint": "此自动化的描述性名称", "automations.name.placeholder": "我的自动化", diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index dfbb446..4a8e70b 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -20,6 +20,15 @@
+ + +
+
+ + Identity + +
+
@@ -45,10 +54,21 @@
-
+
+
+
+ + +
+
+ + Connection + +
+
@@ -303,6 +323,17 @@
+
+
+ + +
+
+ + Output + +
+
@@ -313,6 +344,9 @@
+
+
+
diff --git a/server/src/ledgrab/templates/modals/advanced-calibration.html b/server/src/ledgrab/templates/modals/advanced-calibration.html index 72c2ac1..f1f4c2c 100644 --- a/server/src/ledgrab/templates/modals/advanced-calibration.html +++ b/server/src/ledgrab/templates/modals/advanced-calibration.html @@ -1,4 +1,10 @@ - +