diff --git a/examples/led-matrix-painter/README.md b/examples/led-matrix-painter/README.md index 3ef18f7..5350aad 100644 --- a/examples/led-matrix-painter/README.md +++ b/examples/led-matrix-painter/README.md @@ -1,6 +1,6 @@ # LED Matrix Painter -The **LED Matrix Painter** example provides a web-based interface to draw, animate, and control the built-in LED Matrix of the Arduino UNO Q in real-time. It features a pixel editor with 8-bit brightness control, database storage for your designs, and a code generator to export your frames as ready-to-use C++ code. +The **LED Matrix Painter** example provides a web-based interface to draw, animate, and control the built-in LED Matrix of the Arduino UNO Q in real-time. It features a pixel editor with 3-bit (0-7) brightness control, database storage for your designs, and a code generator to export your frames as ready-to-use C++ code. ![LED Matrix Painter Example](assets/docs_assets/thumbnail.png) @@ -12,7 +12,7 @@ The application uses the `dbstorage_sqlstore` Brick to automatically save your w Key features include: - **Real-time Control:** Drawing on the web grid updates the UNO Q matrix instantly. -- **Grayscale Control:** 8 brightness presets (0-7) for intuitive pixel control, with full 8-bit precision (0-255) supported at the hardware level. +- **Grayscale Control:** 8 brightness presets (0-7) for intuitive pixel control; this example configures the board for 3-bit grayscale (0–7). - **Persistent Storage:** Frames are automatically saved to a database, allowing you to build complex animations over time. - **Transformation Tools:** Invert, rotate, or flip designs with a single click. - **Animation Mode:** Sequence frames to create animations and preview them on the board. @@ -156,8 +156,9 @@ The sketch is designed to be a passive renderer, accepting commands from the Pyt ```cpp void setup() { matrix.begin(); - // configure grayscale bits to 8 so the display can accept 0..255 brightness - matrix.setGrayscaleBits(8); + // configure grayscale bits to 3 so the display accepts 0..7 brightness + // The backend sends quantized values in 0..(2^3-1) == 0..7. + matrix.setGrayscaleBits(3); Bridge.begin(); // ... } diff --git a/examples/led-matrix-painter/assets/app.js b/examples/led-matrix-painter/assets/app.js index 7fcb71c..adee737 100644 --- a/examples/led-matrix-painter/assets/app.js +++ b/examples/led-matrix-painter/assets/app.js @@ -566,7 +566,7 @@ function renderFrames(){ fetchWithHandling('/persist_frame', { method: 'POST', headers: {'Content-Type':'application/json'}, - body: JSON.stringify({ id: f.id, name: newName, duration_ms: f.duration_ms, rows: rows }) + body: JSON.stringify({ id: f.id, name: newName, duration_ms: f.duration_ms, rows: rows, brightness_levels: BRIGHTNESS_LEVELS }) }).then(() => refreshFrames()); }); @@ -577,7 +577,7 @@ function renderFrames(){ fetchWithHandling('/persist_frame', { method: 'POST', headers: {'Content-Type':'application/json'}, - body: JSON.stringify({ id: f.id, name: f.name, duration_ms: durationMs, rows: rows }) + body: JSON.stringify({ id: f.id, name: f.name, duration_ms: durationMs, rows: rows, brightness_levels: BRIGHTNESS_LEVELS }) }).then(() => refreshFrames()); } }); diff --git a/examples/led-matrix-painter/python/app_frame.py b/examples/led-matrix-painter/python/app_frame.py index db66c9b..ebabe7c 100644 --- a/examples/led-matrix-painter/python/app_frame.py +++ b/examples/led-matrix-painter/python/app_frame.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: MPL-2.0 +import re import json from arduino.app_utils import Frame @@ -85,6 +86,8 @@ def __init__( self.name = name self.position = position self.duration_ms = duration_ms + # Export-friendly sanitized name used for C identifiers and exports + self._export_name = self._sanitize_c_ident(self.name or f"frame_{self.id}") # -- JSON serialization/deserialization for frontend -------------------------------- @classmethod @@ -141,15 +144,20 @@ def to_record(self) -> dict: def to_c_string(self) -> str: """Export the frame as a C vector string. - The Frame is rescaled to the 0-255 range prior to exporting as board-compatible C source. + The frame is rescaled to the quantized range [0..brightness_levels-1] + for preview and code-panel output. This produces a `uint8_t` array + initializer suitable for display in the UI's code panel. Returns: str: C source fragment containing a const array initializer. """ - c_type = "uint32_t" - scaled_arr = self.rescale_quantized_frame(scale_max=255) + c_type = "uint8_t" + # use export-friendly sanitized name + snake_name = self._export_name + # represent preview values in the quantized range (0..brightness_levels-1) + scaled_arr = self.rescale_quantized_frame(scale_max=max(1, int(self.brightness_levels) - 1)) - parts = [f"const {c_type} {self.name}[] = {{"] + parts = [f"{c_type} {snake_name} [] = {{"] rows = scaled_arr.tolist() # Emit the array as row-major integer values, preserving row breaks for readability for r_idx, row in enumerate(rows): @@ -161,6 +169,59 @@ def to_c_string(self) -> str: parts.append("};") parts.append("") return "\n".join(parts) + + def to_board_bytes(self) -> bytes: + """Return the byte buffer (row-major) representing this frame for board consumption. + + This overrides ``Frame.to_board_bytes()`` to produce bytes scaled to + the AppFrame's configured ``brightness_levels - 1`` (for example + 0..7 when ``brightness_levels == 8``). The override keeps this + behaviour local to the application layer and avoids modifying the + upstream ``Frame`` implementation. + + Returns: + bytes: Flattened row-major byte sequence suitable for the firmware. + """ + scaled = self.rescale_quantized_frame(scale_max=max(1, int(self.brightness_levels) - 1)) + flat = [int(x) for x in scaled.flatten().tolist()] + return bytes(flat) + + @staticmethod + def _sanitize_c_ident(name: str, fallback: str = "frame") -> str: + """Return a safe C identifier derived from ``name``. + + This produces a lower-case identifier containing only ASCII + letters, digits and underscores. Multiple non-allowed + characters collapse into a single underscore. Leading digits are + prefixed with ``f_`` to ensure the identifier is valid. If the + resulting name is empty, ``fallback`` is returned. + + Args: + name: The original name to sanitize. + fallback: The fallback identifier used when the sanitized + result would be empty. + + Returns: + A sanitized, C-safe identifier string. + """ + + if name is None: + return fallback + s = str(name).strip().lower() + if not s: + return fallback + + # keep letters, digits and underscore + s = re.sub(r'[^a-z0-9_]', '_', s) + # collapse multiple underscores + s = re.sub(r'_+', '_', s) + # remove leading/trailing underscore + s = s.strip('_') + if not s: + return fallback + if re.match(r'^[0-9]', s): + s = f"f_{s}" + return s # -- create empty AppFrame -------------------------------- @classmethod @@ -239,6 +300,74 @@ def to_animation_hex(self) -> list[str]: return hex_values + def to_animation_bytes(self) -> bytes: + """Return this frame encoded as bytes for RPC transmission. + + The returned value is the wire format expected by the sketch's + `play_animation` provider: 5 uint32_t values per frame (4 words + containing packed pixel bits and 1 word for duration), each encoded + in little-endian order (20 bytes total). + + Returns: + bytes: 20-byte little-endian representation of this frame + (4 x uint32_t pixels + 1 x uint32_t duration). + """ + hex_values = self.to_animation_hex() + ba = bytearray() + for i in range(4): + value = int(hex_values[i], 16) + ba.extend(value.to_bytes(4, byteorder='little')) + duration = int(hex_values[4]) + ba.extend(duration.to_bytes(4, byteorder='little')) + return bytes(ba) + + @staticmethod + def frames_to_animation_bytes(frames: list["AppFrame"]) -> bytes: + """Aggregate multiple frames into a single bytes blob ready for RPC. + + Args: + frames (list[AppFrame]): Sequence of AppFrame instances to include + in the resulting animation. + + Returns: + bytes: Concatenated little-endian byte sequence where each frame + contributes 20 bytes (4 uint32_t pixel words + 1 uint32_t duration). + """ + ba = bytearray() + for f in frames: + ba.extend(f.to_animation_bytes()) + return bytes(ba) + + @staticmethod + def frames_to_c_animation_array(frames: list, name: str = 'Animation') -> str: + """Produce a C initializer for an animation sequence. + + Args: + frames (list[AppFrame]): Frames that make up the animation. + name (str): Desired C identifier for the animation array. Will be + sanitized into a valid C identifier. + + Returns: + str: C source fragment defining a `const uint32_t NAME[][5]` array + where each entry is `{word0, word1, word2, word3, duration}`. + + Example: + const uint32_t Animation[][5] = { + {0x..., 0x..., 0x..., 0x..., 1000}, + ... + }; + """ + # sanitize animation name into a simple C identifier + snake = AppFrame._sanitize_c_ident(name or 'Animation') + parts = [f"const uint32_t {snake}[][5] = {{"] + for frame in frames: + hex_values = frame.to_animation_hex() + hex_str = ", ".join(hex_values) + parts.append(f" {{{hex_str}}}, // {getattr(frame, '_export_name', frame.name)}") + parts.append("};") + parts.append("") + return "\n".join(parts) + # -- Frame.from_rows override (for subclass construction only) --------------------------- @classmethod def from_rows( @@ -255,7 +384,12 @@ def from_rows( **Do NOT use it in the app directly, please use `AppFrame.from_json()` or `AppFrame.from_record()` instead.** This method overrides Frame.from_rows which constructs a Frame and it is intended - only for subclass construction and coherence with Frame API. + only for subclass construction and coherence with Frame API and accepts frontend rows either + already expressed in the target brightness range or in 8-bit + representation (0..255). If input values are out-of-range for the + requested ``brightness_levels``, the method will attempt to interpret + the input as 8-bit data and rescale it to the target range + automatically for retrocompatibility with previous versions. We delegate parsing/validation to Frame.from_rows and then construct an AppFrame instance with subclass-specific attributes. @@ -273,12 +407,20 @@ def from_rows( Returns: AppFrame: newly constructed AppFrame instance. """ - # Use Frame to parse rows into a numpy array/frame and to validate it - frame_instance = super().from_rows(rows, brightness_levels=brightness_levels) - - # Get the validated numpy array as a writable copy - arr = frame_instance.arr.copy() - - # Construct an AppFrame using the validated numpy array and brightness_levels - return cls(id, name, position, duration_ms, arr, brightness_levels=frame_instance.brightness_levels) + # Try to parse rows assuming they're already in the requested + # brightness range. If parsing fails because values are out-of-range + # (e.g. legacy rows in 0..255), attempt to parse them as 0..255 and + # rescale to the requested `brightness_levels - 1`. + try: + frame_instance = super().from_rows(rows, brightness_levels=brightness_levels) + arr = frame_instance.arr.copy() + return cls(id, name, position, duration_ms, arr, brightness_levels=frame_instance.brightness_levels) + except ValueError: + # Fallback: parse as 8-bit input and rescale down to target levels + raw = super().from_rows(rows, brightness_levels=256) + # rescale from 0..255 -> 0..(brightness_levels-1) + target_max = max(1, int(brightness_levels) - 1) + scaled = raw.rescale_quantized_frame(scale_max=target_max) + arr = scaled.copy() + return cls(id, name, position, duration_ms, arr, brightness_levels=brightness_levels) diff --git a/examples/led-matrix-painter/python/main.py b/examples/led-matrix-painter/python/main.py index 6f65344..020f347 100644 --- a/examples/led-matrix-painter/python/main.py +++ b/examples/led-matrix-painter/python/main.py @@ -247,17 +247,9 @@ def export_frames(payload: dict = None): if not anim_frames: continue - # Build animation array + # Build animation array (delegated to AppFrame exporter) header_parts.append(f"// Animation: {anim_name}") - header_parts.append(f"const uint32_t {anim_name}[][5] = {{") - - for frame in anim_frames: - hex_values = frame.to_animation_hex() - hex_str = ", ".join(hex_values) - header_parts.append(f" {{{hex_str}}}, // {frame._export_name}") - - header_parts.append("};") - header_parts.append("") + header_parts.append(AppFrame.frames_to_c_animation_array(anim_frames, anim_name)) header = "\n".join(header_parts).strip() + "\n" return {'header': header} @@ -307,17 +299,8 @@ def play_animation(payload: dict): logger.debug(f"Loaded {len(frames)} frames for animation") # Build animation data as bytes (std::vector in sketch) - # Each uint32_t is sent as 4 bytes (little-endian) - animation_bytes = bytearray() - for frame in frames: - hex_values = frame.to_animation_hex() - # First 4 are hex pixel data - for i in range(4): - value = int(hex_values[i], 16) - animation_bytes.extend(value.to_bytes(4, byteorder='little')) - # 5th is duration in ms - duration = int(hex_values[4]) - animation_bytes.extend(duration.to_bytes(4, byteorder='little')) + # Each frame is 20 bytes (4 uint32_t pixels + 1 uint32_t duration), little-endian + animation_bytes = AppFrame.frames_to_animation_bytes(frames) logger.debug(f"Animation data prepared: {len(animation_bytes)} bytes ({len(animation_bytes)//20} frames)") @@ -328,6 +311,27 @@ def play_animation(payload: dict): return {'ok': True, 'frames_played': len(frames)} # Return immediately +def stop_animation(payload: dict = None): + """Stop any running animation on the board. + + This endpoint calls the sketch provider `stop_animation`. No payload + required. + + Args: + payload (dict, optional): Not used. Defaults to None. + + Returns: + dict: {'ok': True} on success, {'error': str} on failure. + """ + try: + Bridge.call("stop_animation") + logger.info("stop_animation called on board") + return {'ok': True} + except Exception as e: + logger.warning(f"Failed to request stop_animation: {e}") + return {'error': str(e)} + + ui.expose_api('POST', '/update_board', update_board) ui.expose_api('POST', '/persist_frame', persist_frame) ui.expose_api('POST', '/load_frame', load_frame) @@ -338,6 +342,7 @@ def play_animation(payload: dict): ui.expose_api('POST', '/export_frames', export_frames) ui.expose_api('POST', '/reorder_frames', reorder_frames) ui.expose_api('POST', '/play_animation', play_animation) +ui.expose_api('POST', '/stop_animation', stop_animation) ui.expose_api('GET', '/config', get_config) App.run() diff --git a/examples/led-matrix-painter/sketch/sketch.ino b/examples/led-matrix-painter/sketch/sketch.ino index cac69d6..90ebed4 100644 --- a/examples/led-matrix-painter/sketch/sketch.ino +++ b/examples/led-matrix-painter/sketch/sketch.ino @@ -12,6 +12,15 @@ Arduino_LED_Matrix matrix; +// Animation playback state (cooperative, interruptible by `stop_animation`) +static const int MAX_FRAMES = 50; +static uint32_t animation_buf[MAX_FRAMES][5]; // 4 words + duration +static int animation_frame_count = 0; +static volatile bool animation_running = false; +static volatile bool animation_loop = false; +static volatile int animation_current_frame = 0; +static unsigned long animation_next_time = 0; + void draw(std::vector frame) { if (frame.empty()) { Serial.println("[sketch] draw called with empty frame"); @@ -52,35 +61,76 @@ void play_animation(std::vector animation_bytes) { frame_count = MAX_FRAMES; } - // Static buffer to avoid dynamic allocation - static uint32_t animation[MAX_FRAMES][5]; - - // Convert bytes to uint32_t array + // Parse bytes into the global animation buffer for cooperative playback const uint8_t* data = animation_bytes.data(); - for (int i = 0; i < frame_count; i++) { + int limit = min(frame_count, MAX_FRAMES); + for (int i = 0; i < limit; i++) { for (int j = 0; j < 5; j++) { int byte_offset = (i * 5 + j) * 4; - // Reconstruct uint32_t from 4 bytes (little-endian) - animation[i][j] = ((uint32_t)data[byte_offset]) | - ((uint32_t)data[byte_offset + 1] << 8) | - ((uint32_t)data[byte_offset + 2] << 16) | - ((uint32_t)data[byte_offset + 3] << 24); + animation_buf[i][j] = ((uint32_t)data[byte_offset]) | + ((uint32_t)data[byte_offset + 1] << 8) | + ((uint32_t)data[byte_offset + 2] << 16) | + ((uint32_t)data[byte_offset + 3] << 24); + } + } + animation_frame_count = limit; + animation_current_frame = 0; + animation_loop = false; // preserve existing behaviour (no loop) + animation_running = true; + animation_next_time = millis(); + Serial.print("[sketch] Animation queued, frames="); + Serial.println(animation_frame_count); +} + +// Provider to stop any running animation +void stop_animation() { + if (!animation_running) { + Serial.println("[sketch] stop_animation called but no animation running"); + return; + } + animation_running = false; + Serial.println("[sketch] stop_animation: animation halted"); +} + +// Cooperative animation tick executed from loop() +void animation_tick() { + if (!animation_running || animation_frame_count == 0) return; + + unsigned long now = millis(); + if (now < animation_next_time) return; + + // Prepare frame words (reverse bits as the library expects) + uint32_t frame[4]; + frame[0] = reverse(animation_buf[animation_current_frame][0]); + frame[1] = reverse(animation_buf[animation_current_frame][1]); + frame[2] = reverse(animation_buf[animation_current_frame][2]); + frame[3] = reverse(animation_buf[animation_current_frame][3]); + + // Display frame + matrixWrite(frame); + + // Schedule next frame + uint32_t interval = animation_buf[animation_current_frame][4]; + if (interval == 0) interval = 1; + animation_next_time = now + interval; + + animation_current_frame++; + if (animation_current_frame >= animation_frame_count) { + if (animation_loop) { + animation_current_frame = 0; + } else { + animation_running = false; + Serial.println("[sketch] Animation finished"); } } - - // Load and play the sequence using the Arduino_LED_Matrix library - matrix.loadWrapper(animation, frame_count * 5 * sizeof(uint32_t)); - matrix.playSequence(false); // Don't loop by default - - Serial.println("[sketch] Animation playback complete"); } void setup() { matrix.begin(); Serial.begin(115200); - // configure grayscale bits to 8 so the display can accept 0..255 brightness - // The MCU expects full-byte brightness values from the backend. - matrix.setGrayscaleBits(8); + // configure grayscale bits to 3 so the display accepts 0..7 brightness + // The backend will send quantized values in 0..(2^3-1) == 0..7. + matrix.setGrayscaleBits(3); matrix.clear(); Bridge.begin(); @@ -91,8 +141,12 @@ void setup() { // Register the animation player provider Bridge.provide("play_animation", play_animation); + // Provider to stop a running animation (invoked by backend) + Bridge.provide("stop_animation", stop_animation); } void loop() { - delay(200); + // Keep loop fast and let animation_tick handle playback timing + animation_tick(); + delay(10); }