Skip to content
9 changes: 5 additions & 4 deletions examples/led-matrix-painter/README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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();
// ...
}
Expand Down
4 changes: 2 additions & 2 deletions examples/led-matrix-painter/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});

Expand All @@ -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());
}
});
Expand Down
168 changes: 155 additions & 13 deletions examples/led-matrix-painter/python/app_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
# SPDX-License-Identifier: MPL-2.0

import re
import json
from arduino.app_utils import Frame

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand All @@ -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)

47 changes: 26 additions & 21 deletions examples/led-matrix-painter/python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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<uint8_t> 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)")

Expand All @@ -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)
Expand All @@ -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()
Loading