From d958043c550bb7c0be4e410862892a6bb0e76612 Mon Sep 17 00:00:00 2001 From: Dario Sammaruga Date: Wed, 5 Nov 2025 11:49:05 +0100 Subject: [PATCH] Add led-matrix-painter example --- examples/led-matrix-painter/README.md | 4 + examples/led-matrix-painter/app.yaml | 9 + examples/led-matrix-painter/assets/app.js | 967 ++++++++++++++++++ examples/led-matrix-painter/assets/index.html | 72 ++ examples/led-matrix-painter/assets/style.css | 62 ++ .../led-matrix-painter/python/app_frame.py | 284 +++++ examples/led-matrix-painter/python/main.py | 338 ++++++ examples/led-matrix-painter/python/store.py | 208 ++++ examples/led-matrix-painter/sketch/sketch.ino | 98 ++ .../led-matrix-painter/sketch/sketch.yaml | 11 + 10 files changed, 2053 insertions(+) create mode 100644 examples/led-matrix-painter/README.md create mode 100644 examples/led-matrix-painter/app.yaml create mode 100644 examples/led-matrix-painter/assets/app.js create mode 100644 examples/led-matrix-painter/assets/index.html create mode 100644 examples/led-matrix-painter/assets/style.css create mode 100644 examples/led-matrix-painter/python/app_frame.py create mode 100644 examples/led-matrix-painter/python/main.py create mode 100644 examples/led-matrix-painter/python/store.py create mode 100644 examples/led-matrix-painter/sketch/sketch.ino create mode 100644 examples/led-matrix-painter/sketch/sketch.yaml diff --git a/examples/led-matrix-painter/README.md b/examples/led-matrix-painter/README.md new file mode 100644 index 0000000..4322726 --- /dev/null +++ b/examples/led-matrix-painter/README.md @@ -0,0 +1,4 @@ +# LED Matrix Designer + +This small example demonstrates a web-based 8x13 matrix designer. It provides: +- \ No newline at end of file diff --git a/examples/led-matrix-painter/app.yaml b/examples/led-matrix-painter/app.yaml new file mode 100644 index 0000000..df0659a --- /dev/null +++ b/examples/led-matrix-painter/app.yaml @@ -0,0 +1,9 @@ +name: Led Matrix Painter +icon: 🟦 +description: + This example shows how to create a tool to design frames for an LED matrix using Arduino. + It provides a web interface where users can design frames and animations and export them as C/C++ code. + +bricks: + - arduino:web_ui + - arduino:dbstorage_sqlstore diff --git a/examples/led-matrix-painter/assets/app.js b/examples/led-matrix-painter/assets/app.js new file mode 100644 index 0000000..9dffd49 --- /dev/null +++ b/examples/led-matrix-painter/assets/app.js @@ -0,0 +1,967 @@ +// SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +// +// SPDX-License-Identifier: MPL-2.0 + +// Simple frontend for 8x13 clickable grid +const gridEl = document.getElementById('grid'); +const vectorEl = document.getElementById('vector'); +const exportBtn = document.getElementById('export'); +const playAnimationBtn = document.getElementById('play-animation'); +const nameInput = document.getElementById('name'); +const clearBtn = document.getElementById('clear'); +const invertBtn = document.getElementById('invert'); +const rotate180Btn = document.getElementById('rotate180'); +const flipHBtn = document.getElementById('flip-h'); +const flipVBtn = document.getElementById('flip-v'); + +const ROWS = 8, COLS = 13; +let BRIGHTNESS_LEVELS = 8; +let cells = []; +let sessionFrames = []; +let selectedFrameId = null; +let loadedFrameId = null; // ID of the frame currently loaded in editor +let loadedFrame = null; // Full frame object currently loaded +let selectedIds = new Set(); // persistent selection of frame ids (survives refreshFrames) + +// Auto-persist timer (unified: board + DB together) +let persistTimeout = null; +const AUTO_PERSIST_DELAY_MS = 150; // 150ms unified delay + +async function loadConfig(){ + try{ + const resp = await fetch('/config'); + if(!resp.ok) return; + const data = await resp.json(); + if(typeof data.brightness_levels === 'number' && data.brightness_levels >= 2){ + BRIGHTNESS_LEVELS = data.brightness_levels; + } + }catch(err){ + console.warn('[ui] unable to load config; using defaults', err); + } + const maxValue = Math.max(0, BRIGHTNESS_LEVELS - 1); + if(brightnessSlider){ + brightnessSlider.max = String(maxValue); + if(parseInt(brightnessSlider.value || '0') > maxValue){ + brightnessSlider.value = String(maxValue); + } + } + if(brightnessValue){ + const current = brightnessSlider ? parseInt(brightnessSlider.value) : maxValue; + brightnessValue.textContent = String(Math.min(current, maxValue)); + } +} + +function clampBrightness(v){ + if(Number.isNaN(v) || v < 0) return 0; + const maxValue = Math.max(0, BRIGHTNESS_LEVELS - 1); + return Math.min(v, maxValue); +} + +function collectGridBrightness(){ + const grid = []; + for(let r=0;r{ ev.stopPropagation(); cellClicked(ev, el); }); + gridEl.appendChild(el); + cells.push(el); + } + } +} + +const sliderWrap = document.getElementById('cell-slider'); +const brightnessSlider = document.getElementById('brightness-slider'); +const brightnessValue = document.getElementById('brightness-value'); +let activeCell = null; + +function cellClicked(ev, el){ + activeCell = el; + // position slider near cursor if present; otherwise just keep selection + if (sliderWrap && brightnessSlider) { + try { + sliderWrap.style.left = (ev.clientX + 8) + 'px'; + sliderWrap.style.top = (ev.clientY + 8) + 'px'; + const current = clampBrightness(el.dataset.b ? parseInt(el.dataset.b) : 0); + brightnessSlider.value = String(current); + if (brightnessValue) brightnessValue.textContent = String(current); + if (sliderWrap) { sliderWrap.style.display = 'flex'; } + } catch (err) { + console.warn('[ui] failed to position slider', err); + } + } else { + // fallback: ensure visual state reflects dataset.b + const current = clampBrightness(el.dataset.b ? parseInt(el.dataset.b) : 0); + if (current > 0) el.classList.add('on'); else el.classList.remove('on'); + // user has toggled a cell visually without using the slider -> this counts as an edit + clearLoaded(); + } +} + +if (brightnessSlider) { + brightnessSlider.addEventListener('input', ()=>{ + if(!activeCell) return; + const v = clampBrightness(parseInt(brightnessSlider.value)); + brightnessSlider.value = String(v); + activeCell.dataset.b = String(v); + // visually mark as 'on' if v>0 + if(v>0) activeCell.classList.add('on'); else activeCell.classList.remove('on'); + // update numeric display next to slider + if (brightnessValue) brightnessValue.textContent = String(v); + }); + + brightnessSlider.addEventListener('change', ()=>{ + // commit change: send full 2D array rows of ints to backend + const committed = clampBrightness(parseInt(brightnessSlider.value)); + brightnessSlider.value = String(committed); + console.debug('[ui] brightness change commit for active cell, value=', committed); + + // Trigger unified persist (board + DB) + schedulePersist(); + + // hide slider + if (sliderWrap) sliderWrap.style.display = 'none'; + activeCell = null; + }); +} else { + console.warn('[ui] brightness-slider element not found; per-cell slider disabled'); +} + +loadConfig(); + +// Hide the slider when clicking anywhere outside the slider or the grid +document.addEventListener('click', (e) => { + if (!sliderWrap) return; + if (sliderWrap.contains(e.target)) return; + if (gridEl && gridEl.contains(e.target)) return; + sliderWrap.style.display = 'none'; +}); + +// Unified persist: save to DB and update board together +function schedulePersist(){ + if (persistTimeout) clearTimeout(persistTimeout); + persistTimeout = setTimeout(()=> { + persistFrame(); + persistTimeout = null; + }, AUTO_PERSIST_DELAY_MS); +} + +function persistFrame(){ + const grid = collectGridBrightness(); + // Backend is responsible for naming - send empty if no value + const frameName = nameInput.value.trim() || (loadedFrame && loadedFrame.name) || ''; + const duration_ms = durationInput && durationInput.value ? parseInt(durationInput.value) : 1000; + + // Build payload with ID if we're updating an existing frame + const payload = { + rows: grid, + name: frameName, + duration_ms: duration_ms, + brightness_levels: BRIGHTNESS_LEVELS + }; + + if (loadedFrame && loadedFrame.id) { + payload.id = loadedFrame.id; + payload.position = loadedFrame.position; + } + + console.debug('[ui] persistFrame (save to DB + update board)', payload); + + fetch('/persist_frame', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify(payload) + }).then(r => r.json()) + .then(data => { + if (data && data.ok && data.frame) { + // Update loaded frame reference + loadedFrame = data.frame; + loadedFrameId = data.frame.id; + // Show vector text + if (data.vector) showVectorText(data.vector); + // Refresh frames list to show updated version + refreshFrames(); + console.debug('[ui] frame persisted:', data.frame.id); + } + }) + .catch(err => console.warn('[ui] persistFrame failed', err)); +} + +function sendUpdateFromGrid(){ + // Legacy function - now calls schedulePersist + schedulePersist(); +} + +function getRows13(){ + const rows = []; + for(let r=0;r i.checked); + return checked ? checked.value : 'frames'; +} + +async function exportH(){ + exportBtn.disabled = true; + try { + const mode = getMode(); + if(mode === 'frames'){ + // Export all frames (session) + const resp = await fetch('/export_frames', {method:'POST'}); + if(!resp.ok){ + showVectorText('Server error'); + return; + } + const data = await resp.json(); + // Download file without updating the vector display + const blob = new Blob([data.header], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'frames.h'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } else { + // Animations mode: use the Animation name field as file name and animation name + const container = document.getElementById('frames'); + let selected = Array.from(container.children).filter(ch => ch.dataset.selected === '1').map(ch => parseInt(ch.dataset.id)); + if(selected.length === 0){ + // default to all frames if none selected + selected = sessionFrames.map(f => f.id); + } + const animName = animNameInput && animNameInput.value && animNameInput.value.trim() ? animNameInput.value.trim() : 'Animation'; + const payload = { frames: selected, animations: [{name: animName, frames: selected}] }; + console.debug('[ui] exportH animation payload', payload, 'sessionFrames=', sessionFrames.map(f=>f.id)); + const resp = await fetch('/export_frames', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)}); + if(!resp.ok){ + showVectorText('Server error'); + return; + } + const data = await resp.json(); + // Download file without updating the vector display + const blob = new Blob([data.header], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = (animName || 'Animation') + '.h'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + } catch (err) { + showVectorText('Error: ' + (err.message || err)); + } finally { + exportBtn.disabled = false; + } +} + +makeGrid(); +if (exportBtn) exportBtn.addEventListener('click', exportH); else console.warn('[ui] export button not found'); + +async function playAnimation() { + if (!playAnimationBtn) return; + + try { + playAnimationBtn.disabled = true; + + const mode = getMode(); + + if (mode === 'frames') { + // Frames mode: play all frames in order + const frameIds = sessionFrames.map(f => f.id); + + if (frameIds.length === 0) { + showVectorText('No frames to play'); + return; + } + + console.debug('[ui] playAnimation frames mode, frameIds=', frameIds); + + const payload = { + frames: frameIds, + loop: false + }; + + const resp = await fetch('/play_animation', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + + if (!resp.ok) { + showVectorText('Error playing animation'); + return; + } + + const data = await resp.json(); + if (data.error) { + showVectorText('Error: ' + data.error); + } else { + console.debug('[ui] Animation played successfully, frames=', data.frames_played); + showVectorText('Animation played: ' + data.frames_played + ' frames'); + } + } else { + // Animation mode: play selected frames + if (selectedIds.size === 0) { + showVectorText('No frames selected for animation'); + return; + } + + const frameIds = Array.from(selectedIds); + + console.debug('[ui] playAnimation animation mode, selected frameIds=', frameIds); + + const payload = { + frames: frameIds, + loop: false + }; + + const resp = await fetch('/play_animation', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + + if (!resp.ok) { + showVectorText('Error playing animation'); + return; + } + + const data = await resp.json(); + if (data.error) { + showVectorText('Error: ' + data.error); + } else { + console.debug('[ui] Animation played successfully, frames=', data.frames_played); + showVectorText('Animation played: ' + data.frames_played + ' frames'); + } + } + } catch (err) { + showVectorText('Error: ' + (err.message || err)); + } finally { + playAnimationBtn.disabled = false; + } +} + +if (playAnimationBtn) playAnimationBtn.addEventListener('click', playAnimation); else console.warn('[ui] play animation button not found'); + +// Save frame button removed - auto-persist replaces it +const modeInputs = document.getElementsByName('mode'); +const animControls = document.getElementById('anim-controls'); +const animNameInput = document.getElementById('anim-name'); +const durationInput = document.getElementById('duration'); +// set default placeholder and default value +if (animNameInput) { + animNameInput.placeholder = 'Animation name (optional)'; + animNameInput.value = 'Animation'; +} + +// Enforce simple C-identifier rule on name inputs for exported symbols. +function normalizeSymbolInput(s){ + if(!s) return ''; + // Replace invalid chars with '_', and remove leading digits by prefixing 'f_' + let cand = ''; + for(const ch of s){ + if(/[A-Za-z0-9_]/.test(ch)) cand += ch; else cand += '_'; + } + if(/^[0-9]/.test(cand)) cand = 'f_' + cand; + return cand; +} + +if(nameInput){ + nameInput.addEventListener('input', ()=>{ + // Schedule persist when name changes + schedulePersist(); + }); + nameInput.addEventListener('blur', ()=>{ + nameInput.value = normalizeSymbolInput(nameInput.value.trim()) || ''; + // Trigger immediate persist on blur + if (persistTimeout) clearTimeout(persistTimeout); + persistFrame(); + }); +} + +if(durationInput){ + durationInput.addEventListener('input', ()=>{ + // Schedule persist when duration changes + schedulePersist(); + }); + durationInput.addEventListener('blur', ()=>{ + // Trigger immediate persist on blur + if (persistTimeout) clearTimeout(persistTimeout); + persistFrame(); + }); +} + +if(animNameInput){ + animNameInput.addEventListener('blur', ()=>{ + animNameInput.value = normalizeSymbolInput(animNameInput.value.trim()) || ''; + }); +} + +// Save frame button removed - using auto-persist instead + +async function refreshFrames(){ + try{ + const resp = await fetch('/list_frames'); + const data = await resp.json(); + sessionFrames = data.frames || []; + renderFrames(); + + // Re-apply loaded state after rendering + if(loadedFrameId !== null && loadedFrame !== null){ + const el = document.querySelector(`#frames [data-id='${loadedFrameId}']`); + if(el) el.classList.add('loaded'); + } + }catch(e){ console.warn(e) } +} + +function renderFrames(){ + const container = document.getElementById('frames'); + container.innerHTML = ''; + sessionFrames.forEach(f => { + const item = document.createElement('div'); item.className = 'frame-item'; item.draggable = true; item.dataset.id = f.id; + const thumb = document.createElement('div'); thumb.className = 'frame-thumb'; + // render a tiny grid by mapping the rows into colored blocks + const rows = f.rows || []; + // create 8*13 small cells but the CSS makes them tiny + for(let r=0;r 0; + } else if (typeof row === 'string') { + isOn = row[c] === '1'; + } + const dot = document.createElement('div'); dot.style.background = isOn ? '#0b76ff' : '#fff'; thumb.appendChild(dot); + } + } + const name = document.createElement('div'); name.className = 'frame-name'; name.textContent = f.name || ('Frame'+f.id); + const actions = document.createElement('div'); actions.className = 'frame-actions'; + const loadBtn = document.createElement('button'); loadBtn.textContent = 'Load'; + loadBtn.addEventListener('click', ()=> loadFrameIntoEditor(f.id)); + const delBtn = document.createElement('button'); delBtn.textContent = 'Del'; + delBtn.addEventListener('click', async ()=>{ + const deletingLoadedFrame = (loadedFrameId === f.id); + await deleteFrame(f.id); + + // If we deleted the currently loaded frame, clear editor and load another frame + if (deletingLoadedFrame) { + clearLoaded(); + cells.forEach(c => { c.classList.remove('on'); delete c.dataset.b; }); + if(nameInput) nameInput.value = ''; + if(durationInput) durationInput.value = '1000'; + showVectorText(''); + + // Load the first available frame or let backend create empty one + await refreshFrames(); // Update list after deletion + + if (sessionFrames.length > 0) { + // Find a frame that's not the one we just deleted + const nextFrame = sessionFrames.find(fr => fr.id !== f.id); + if (nextFrame) { + await loadFrameIntoEditor(nextFrame.id); + } + } else { + // No frames left, let backend create one with proper naming + await loadFrameIntoEditor(); // no ID = backend creates empty frame with Frame{id} name + // Refresh again to show the newly created frame + await refreshFrames(); + } + } else { + // Not deleting loaded frame, just refresh the list + await refreshFrames(); + } + }); + // toggle selection for animations when clicking the thumb + item.addEventListener('click', (e)=>{ + // avoid toggling when clicking the load/del buttons + if(e.target === loadBtn || e.target === delBtn) return; + + // Only allow selection toggle in animations mode + const mode = getMode(); + if (mode !== 'anim') return; + + const id = parseInt(item.dataset.id); + if(selectedIds.has(id)){ + selectedIds.delete(id); + item.classList.remove('selected'); + item.dataset.selected = '0'; + } else { + selectedIds.add(id); + item.classList.add('selected'); + item.dataset.selected = '1'; + } + }); + + // drag/drop handlers + item.addEventListener('dragstart', (ev)=>{ ev.dataTransfer.setData('text/plain', f.id); item.classList.add('dragging'); }); + item.addEventListener('dragend', ()=>{ item.classList.remove('dragging'); }); + item.addEventListener('dragover', (ev)=>{ ev.preventDefault(); item.classList.add('dragover'); }); + item.addEventListener('dragleave', ()=>{ item.classList.remove('dragover'); }); + item.addEventListener('drop', async (ev)=>{ + ev.preventDefault(); item.classList.remove('dragover'); + const draggedId = parseInt(ev.dataTransfer.getData('text/plain')); + const draggedEl = container.querySelector(`[data-id='${draggedId}']`); + if(draggedEl && draggedEl !== item){ + // Determine if we should insert before or after based on mouse position + const rect = item.getBoundingClientRect(); + const mouseY = ev.clientY; + const itemMiddle = rect.top + rect.height / 2; + + if (mouseY < itemMiddle) { + // Drop in upper half: insert before + container.insertBefore(draggedEl, item); + } else { + // Drop in lower half: insert after + container.insertBefore(draggedEl, item.nextSibling); + } + + // compute new order and send to backend + const order = Array.from(container.children).map(ch => parseInt(ch.dataset.id)); + await fetch('/reorder_frames', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({order})}); + await refreshFrames(); + } + }); + actions.appendChild(loadBtn); actions.appendChild(delBtn); + item.appendChild(thumb); item.appendChild(name); item.appendChild(actions); + + // Set selection state from selectedIds (for animations) + if(selectedIds.has(f.id)){ + item.classList.add('selected'); + item.dataset.selected = '1'; + } else { + item.dataset.selected = '0'; + } + + // Set loaded state (frame currently in editor) + if(loadedFrameId === f.id){ + item.classList.add('loaded'); + } + + container.appendChild(item); + }); +} + +// Save animation: collect selected frames and POST to backend +const saveAnimBtn = document.getElementById('save-anim'); +// list-anims button removed from UI +// const listAnimsBtn = document.getElementById('list-anims'); +if (saveAnimBtn) { + saveAnimBtn.addEventListener('click', async ()=>{ + const container = document.getElementById('frames'); + const selected = Array.from(container.children).filter(ch => ch.dataset.selected === '1').map(ch => parseInt(ch.dataset.id)); + if(selected.length === 0) { alert('Select some frames first'); return; } + const animName = animNameInput.value && animNameInput.value.trim() ? animNameInput.value.trim() : undefined; + const resp = await fetch('/save_animation', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name: animName, frames: selected})}); + const data = await resp.json(); + if(data && data.anim){ + // clear selection + Array.from(container.children).forEach(ch => { ch.classList.remove('selected'); ch.dataset.selected = '0'; }); + animNameInput.value = ''; + alert('Animation saved'); + } + }); +} else { + console.warn('[ui] save-anim button not found (animation save disabled)'); +} + +// Mode toggle handling +Array.from(modeInputs).forEach(i=> i.addEventListener('change', async ()=>{ + const mode = Array.from(modeInputs).find(x=>x.checked).value; + if(mode === 'anim'){ + animControls.style.display = 'flex'; + // ensure we have the latest frames when switching to Animations mode + await refreshFrames(); + // Auto-select all frames by default when entering Animations mode so + // exporting immediately will include existing frames the user created + // in Frames mode. + selectedIds = new Set((sessionFrames || []).map(f => f.id)); + // reflect selection in the DOM + const container = document.getElementById('frames'); + if(container){ + Array.from(container.children).forEach(ch => { + const id = parseInt(ch.dataset.id); + if(selectedIds.has(id)){ + ch.classList.add('selected'); ch.dataset.selected = '1'; + } else { + ch.classList.remove('selected'); ch.dataset.selected = '0'; + } + }); + } + } else { + animControls.style.display = 'none'; + // clear any animation selections when leaving animations mode + selectedIds = new Set(); + const container = document.getElementById('frames'); + if(container){ Array.from(container.children).forEach(ch => { ch.classList.remove('selected'); ch.dataset.selected = '0'; }); } + } +})); + +// Transform button handlers +if (rotate180Btn) { + rotate180Btn.addEventListener('click', async ()=>{ + const grid = collectGridBrightness(); + try{ + const resp = await fetch('/transform_frame', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ + op:'rotate180', + rows: grid, + brightness_levels: BRIGHTNESS_LEVELS + }) + }); + const data = await resp.json(); + if(data && data.ok && data.frame) { + setGridFromRows(data.frame.rows); + if(data.vector) showVectorText(data.vector); + schedulePersist(); + } + }catch(e){ console.warn('[ui] rotate180 failed', e); } + }); +} +if (flipHBtn) { + flipHBtn.addEventListener('click', async ()=>{ + const grid = collectGridBrightness(); + try{ + const resp = await fetch('/transform_frame', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ + op:'flip_h', + rows: grid, + brightness_levels: BRIGHTNESS_LEVELS + }) + }); + const data = await resp.json(); + if(data && data.ok && data.frame) { + setGridFromRows(data.frame.rows); + if(data.vector) showVectorText(data.vector); + schedulePersist(); + } + }catch(e){ console.warn('[ui] flip-h failed', e); } + }); +} +if (flipVBtn) { + flipVBtn.addEventListener('click', async ()=>{ + const grid = collectGridBrightness(); + try{ + const resp = await fetch('/transform_frame', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ + op:'flip_v', + rows: grid, + brightness_levels: BRIGHTNESS_LEVELS + }) + }); + const data = await resp.json(); + if(data && data.ok && data.frame) { + setGridFromRows(data.frame.rows); + if(data.vector) showVectorText(data.vector); + schedulePersist(); + } + }catch(e){ console.warn('[ui] flip-v failed', e); } + }); +} + +async function loadFrameIntoEditor(id){ + try { + const resp = await fetch('/load_frame', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({id}) + }); + const data = await resp.json(); + + if(data && data.ok && data.frame){ + const f = data.frame; + loadedFrame = f; + loadedFrameId = f.id; + + // Populate grid + setGridFromRows(f.rows || []); + + // Populate name input + if(nameInput) nameInput.value = f.name || ''; + + // Populate duration + if(durationInput) durationInput.value = (f.duration_ms !== undefined && f.duration_ms !== null) ? String(f.duration_ms) : '1000'; + + // Mark as loaded in sidebar + markLoaded(f); + + // Show C vector representation (backend already sends it via load_frame) + if (data.vector) { + showVectorText(data.vector); + } + + selectedFrameId = id; + + console.debug('[ui] loaded frame into editor:', id); + } + } catch(err) { + console.warn('[ui] loadFrameIntoEditor failed', err); + } +} + +function setGridFromRows(rows){ + // rows: either list[list[int]] or list[str] + for(let r=0;r 0) { cells[idx].classList.add('on'); cells[idx].dataset.b = String(v); } else { cells[idx].classList.remove('on'); delete cells[idx].dataset.b; } + } else { + const s = (row || '').padEnd(COLS,'0'); + if(s[c] === '1') { cells[idx].classList.add('on'); cells[idx].dataset.b = String(Math.max(0, BRIGHTNESS_LEVELS - 1)); } else { cells[idx].classList.remove('on'); delete cells[idx].dataset.b; } + } + } + } +} + +function selectFrame(id, add=false){ + selectedFrameId = id; + const container = document.getElementById('frames'); + Array.from(container.children).forEach(ch => { + const isMatch = parseInt(ch.dataset.id) === id; + if(add){ + // keep existing selections, only set this one to selected + if(isMatch){ ch.classList.add('selected'); ch.dataset.selected = '1'; } + } else { + // exclusive selection + ch.classList.toggle('selected', isMatch); + ch.dataset.selected = isMatch ? '1' : '0'; + } + }); +} + +async function deleteFrame(id){ + await fetch('/delete_frame', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})}); +} + +// Initialize editor on page load +initEditor(); +refreshFrames(); + +// New frame button: creates a new empty frame +const newFrameBtn = document.getElementById('new-frame'); +if (newFrameBtn) { + newFrameBtn.addEventListener('click', async ()=>{ + console.debug('[ui] new frame button clicked'); + + // Clear editor + cells.forEach(c => { c.classList.remove('on'); delete c.dataset.b; }); + if(nameInput) nameInput.value = ''; + if(durationInput) durationInput.value = '1000'; + showVectorText(''); + + // Clear loaded frame reference (we're creating new) + clearLoaded(); + + // Create empty frame in DB (no name = backend assigns progressive name) + const grid = collectGridBrightness(); // all zeros + try { + const resp = await fetch('/persist_frame', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ + rows: grid, + name: '', // empty name = backend will assign Frame{id} + duration_ms: 1000, + brightness_levels: BRIGHTNESS_LEVELS + }) + }); + const data = await resp.json(); + + if (data && data.ok && data.frame) { + loadedFrame = data.frame; + loadedFrameId = data.frame.id; + // Set name to the backend-assigned name (Frame{id}) + if(nameInput) nameInput.value = data.frame.name || `Frame${data.frame.id}`; + + // Show C vector representation + if (data.vector) { + showVectorText(data.vector); + } + + // Refresh frames list + await refreshFrames(); + + // Mark as loaded + markLoaded(data.frame); + + console.debug('[ui] new frame created:', data.frame.id); + } + } catch(err) { + console.warn('[ui] failed to create new frame', err); + } + }); +} else { + console.warn('[ui] new-frame button not found'); +} + +if (clearBtn) { + clearBtn.addEventListener('click', ()=>{ + console.debug('[ui] clear button clicked'); + cells.forEach(c => { c.classList.remove('on'); delete c.dataset.b; }); + showVectorText(''); + schedulePersist(); + }); +} else { + console.warn('[ui] clear button not found'); +} + +if (invertBtn) { + invertBtn.addEventListener('click', async ()=>{ + console.debug('[ui] invert button clicked (delegating to server)'); + const grid = collectGridBrightness(); + try{ + const resp = await fetch('/transform_frame', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ + op:'invert', + rows: grid, + brightness_levels: BRIGHTNESS_LEVELS + }) + }); + const data = await resp.json(); + if(data && data.ok && data.frame){ + setGridFromRows(data.frame.rows); + if(data.vector) showVectorText(data.vector); + schedulePersist(); + } + }catch(e){ console.warn('[ui] transform request failed', e); } + }); +} else { + console.warn('[ui] invert button not found'); +} + +const invertNotNullBtn = document.getElementById('invert-not-null'); +if (invertNotNullBtn) { + invertNotNullBtn.addEventListener('click', async ()=>{ + console.debug('[ui] invert-not-null clicked (delegating to server)'); + const grid = collectGridBrightness(); + try{ + const resp = await fetch('/transform_frame', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ + op:'invert_not_null', + rows: grid, + brightness_levels: BRIGHTNESS_LEVELS + }) + }); + const data = await resp.json(); + if(data && data.ok && data.frame) { + setGridFromRows(data.frame.rows); + if(data.vector) showVectorText(data.vector); + schedulePersist(); + } + }catch(e){ console.warn('[ui] invert-not-null failed', e); } + }); +} else { + console.warn('[ui] invert-not-null button not found'); +} diff --git a/examples/led-matrix-painter/assets/index.html b/examples/led-matrix-painter/assets/index.html new file mode 100644 index 0000000..a9254de --- /dev/null +++ b/examples/led-matrix-painter/assets/index.html @@ -0,0 +1,72 @@ + + + + + + + + LED Matrix Painter + + + +
+
+

LED Matrix Painter

+
+ + +
+
+ +
+ + + + + + +
+ +
+ + +
+ + + +
+ + +
+
+
+ + + diff --git a/examples/led-matrix-painter/assets/style.css b/examples/led-matrix-painter/assets/style.css new file mode 100644 index 0000000..ae28ae8 --- /dev/null +++ b/examples/led-matrix-painter/assets/style.css @@ -0,0 +1,62 @@ +/* +SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA + +SPDX-License-Identifier: MPL-2.0 +*/ + +.body-wrapper{display:flex;align-items:center;justify-content:center;height:100vh;margin:0;width:100%} +body{font-family:system-ui,Segoe UI,Roboto,Arial;margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh} +.main{width:1200px;max-width:98vw;margin:20px auto} +.grid{display:grid;grid-template-columns:repeat(13,32px);grid-auto-rows:32px;gap:6px;margin:16px 0} +.cell{width:32px;height:32px;border-radius:4px;border:1px solid #bbb;background:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer} +.cell.on{background:#0b76ff} +/* brightness visual using background color intensity */ +#cell-slider{padding:6px;background:#fff;border:1px solid #ddd;border-radius:6px} +.cell[data-b='0']{ background:#fff } +.cell[data-b='1']{ background:#e6f3ff } +.cell[data-b='2']{ background:#cfe9ff } +.cell[data-b='3']{ background:#b8defe } +.cell[data-b='4']{ background:#9fcffb } +.cell[data-b='5']{ background:#7fc0f7 } +.cell[data-b='6']{ background:#4fa8f0 } +.cell[data-b='7']{ background:#0b76ff } +#cell-slider{padding:6px;background:#fff;border:1px solid #ddd;border-radius:6px} +#cell-slider{padding:6px;background:#fff;border:1px solid #ddd;border-radius:6px} +.controls{display:flex;gap:8px;align-items:center} +.out{height:180px;overflow:auto;background:#f7f7f7;padding:8px;border:1px solid #eee} +input#name{flex:1;padding:6px} +button{padding:6px 10px} + +/* Layout for sidebar with frame previews */ +.layout{display:flex;gap:20px;align-items:stretch} +.sidebar{width:320px;background:#fff;border:1px solid #eee;padding:14px;border-radius:6px;display:flex;flex-direction:column;min-height:400px} +.maincol{width:488px;flex: none;display:flex;flex-direction:column;min-height:400px} +.rightcol{width:320px;padding-left:12px;display:flex;flex-direction:column;min-height:400px} +.sidebar-header{display:flex;gap:8px;justify-content:space-between;margin-bottom:8px} +.frames{display:flex;flex-direction:column;gap:8px;max-height:400px;overflow:auto} +.frame-item{display:flex;align-items:center;gap:8px;padding:6px;border:1px solid #f0f0f0;border-radius:4px;background:#fafafa} +.frame-thumb{ + /* Thumbnail for a 13x8 matrix: keep the same aspect ratio as the main grid */ + width:65px; + height:40px; + background:#fff; + border:1px solid #ddd; + display:grid; + grid-template-columns:repeat(13,1fr); + grid-auto-rows:1fr; + gap:1px; + box-sizing:border-box; +} +.frame-thumb > div{border-radius:1px} + +/* make the vector pre fill its column vertically */ +#vector{flex:1;box-sizing:border-box;overflow:auto;padding:8px;background:#f7f7f7;border:1px solid #ddd} +/* ensure out class doesn't impose fixed height now */ +.out{height:auto} +.frame-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.frame-actions{display:flex;gap:6px} +.frame-item.dragging{opacity:0.5} +.frame-item.dragover{outline:2px dashed #0b76ff} +.frame-item.loaded{box-shadow:0 0 0 3px rgba(11,118,255,0.12);border-color:rgba(11,118,255,0.35)} +.frame-item.loaded .frame-name{font-weight:700} +.animations-list{margin-top:8px} diff --git a/examples/led-matrix-painter/python/app_frame.py b/examples/led-matrix-painter/python/app_frame.py new file mode 100644 index 0000000..b94c7ea --- /dev/null +++ b/examples/led-matrix-painter/python/app_frame.py @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +import json +from arduino.app_utils import Frame + +class AppFrame(Frame): + """Extended Frame app_utils class with application-specific metadata. + + This subclass of `arduino.app_utils.Frame` takes care of Frame validation + and array management while adding application-specific attributes used + in the LED matrix tool app. + + Usage: + + # Create from JSON-serializable dict from API payload + frame = AppFrame.from_json({ + "id": 1, + "name": "My Frame", + "position": 0, + "duration_ms": 1000, + "rows": [[0, 255, 0], [255, 0, 255]], + "brightness_levels": 256, + }) + + # Convert to JSON-serializable dict for API responses + json_dict = frame.to_json() + + # Create from database record dict + record = { + "id": 1, + "name": "My Frame", + "position": 0, + "duration_ms": 1000, + "rows": json.dumps([[0, 255, 0], [255, 0, 255]]), + "brightness_levels": 256, + } + frame = AppFrame.from_record(record) + + # Convert to database record dict for storage + record_dict = frame.to_record() + + # Create empty AppFrame + empty_frame = AppFrame.create_empty( + id=2, + name="Empty Frame", + position=1, + duration_ms=500, + brightness_levels=256, + ) + + # Export to C string for embedding in source code + c_string = frame.to_c_string() + + # Mutate array values in-place + frame.set_value(0, 0, 128) + + # Mutate array in-place + frame.set_array(frame.arr * 0.5) + """ + def __init__( + self, + id: int, + name: str, + position: int, + duration_ms: int, + arr, + brightness_levels: int = 256 + ): + """Initialize the AppFrame instance with application-specific attributes. + + Args: + arr (numpy.ndarray): The array data for the frame. + brightness_levels (int): Number of brightness levels (default 256). + + Attributes: + id (int): database ID of the frame. + name (str): user-defined name of the frame. + position (int): user-defined position/order of the frame. + duration_ms (int): duration in milliseconds for animated frames. + """ + super().__init__(arr, brightness_levels=brightness_levels) # Initialize base Frame attributes + self.id = id + self.name = name + self.position = position + self.duration_ms = duration_ms + + # -- JSON serialization/deserialization for frontend -------------------------------- + @classmethod + def from_json(cls, data: dict) -> "AppFrame": + """Reconstruct an AppFrame from a JSON-serializable dict. + + This is the constructor used both for frontend payloads and + for DB records. + """ + id = data.get('id') + name = data.get('name') + position = data.get('position') + duration_ms = data.get('duration_ms') + rows = data.get('rows') + brightness_levels = data.get('brightness_levels') + return cls.from_rows(id, name, position, duration_ms, rows, brightness_levels=brightness_levels) + + def to_json(self) -> dict: + """Convert to a JSON-serializable dict for API responses""" + return { + "id": self.id, + "name": self.name, + "rows": self.arr.tolist(), + "brightness_levels": int(self.brightness_levels), + "position": self.position, + "duration_ms": int(self.duration_ms) if self.duration_ms is not None else 1000 + } + + # -- record serialization/deserialization for DB storage -------------------------------- + + @classmethod + def from_record(cls, record: dict) -> "AppFrame": + """Reconstruct an AppFrame from a database record dict.""" + id = record.get('id') + name = record.get('name') + position = record.get('position') + duration_ms = record.get('duration_ms') + rows = json.loads(record.get('rows')) + brightness_levels = record.get('brightness_levels') + return cls.from_rows(id, name, position, duration_ms, rows, brightness_levels=brightness_levels) + + def to_record(self) -> dict: + """Convert to a database record dict for storage.""" + return { + "id": self.id, + "name": self.name, + "rows": json.dumps(self.arr.tolist()), + "brightness_levels": int(self.brightness_levels), + "position": self.position, + "duration_ms": int(self.duration_ms) if self.duration_ms is not None else 1000 + } + + # -- other exports ---------------------------------------------------- + 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. + + Returns: + str: C source fragment containing a const array initializer. + """ + c_type = "uint8_t" + scaled_arr = self.rescale_quantized_frame(scale_max=255) + + parts = [f"const {c_type} {self.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): + line = ", ".join(str(int(v)) for v in row) + if r_idx < len(rows) - 1: + parts.append(f" {line},") + else: + parts.append(f" {line}") + parts.append("};") + parts.append("") + return "\n".join(parts) + + # -- create empty AppFrame -------------------------------- + @classmethod + def create_empty( + cls, + id: int, + name: str, + position: int, + duration_ms: int, + brightness_levels: int = 256, + ) -> "AppFrame": + """Create an empty AppFrame with all pixels set to 0. + + Args: + id (int): database ID of the frame. + name (str): user-defined name of the frame. + position (int): user-defined position/order of the frame. + duration_ms (int): duration in milliseconds for animated frames. + width (int): width of the frame in pixels. + height (int): height of the frame in pixels. + brightness_levels (int): number of brightness levels (default 256). + + Returns: + AppFrame: newly constructed empty AppFrame instance. + """ + import numpy as np + height = 8 + width = 13 + arr = np.zeros((height, width), dtype=np.uint8) + return cls(id, name, position, duration_ms, arr, brightness_levels=brightness_levels) + + # -- array/value in-place mutations wrappers -------------------------------- + def set_array(self, arr) -> "AppFrame": + super().set_array(arr) + return self + + def set_value(self, row: int, col: int, value: int) -> None: + return super().set_value(row, col, value) + + # -- animation export -------------------------------- + def to_animation_hex(self) -> list[str]: + """Convert frame to animation format: 5 hex strings [hex0, hex1, hex2, hex3, duration_ms]. + + This format is used by Arduino_LED_Matrix library for animations. + Each frame in an animation is represented as: + - 4 uint32_t values (128 bits total) for binary pixel data + - 1 uint32_t value for duration in milliseconds + + Returns: + list[str]: List of 5 hex strings in format ["0xHHHHHHHH", "0xHHHHHHHH", "0xHHHHHHHH", "0xHHHHHHHH", "duration"] + """ + # Rescale to 0-255 range for threshold + arr_scaled = self.rescale_quantized_frame(scale_max=255) + height, width = arr_scaled.shape + + # Convert to binary presence (non-zero pixels -> 1) + pixels = (arr_scaled > 0).astype(int).flatten().tolist() + + # Pad to 128 bits (4 * 32) + if len(pixels) > 128: + raise ValueError(f"Pixel buffer too large: {len(pixels)} > 128") + pixels += [0] * (128 - len(pixels)) + + # Pack into 4 uint32_t hex values + hex_values = [] + for i in range(0, 128, 32): + value = 0 + for j in range(32): + bit = int(pixels[i + j]) & 1 + value |= bit << (31 - j) + hex_values.append(f"0x{value:08x}") + + # Append duration_ms as last value + duration = int(self.duration_ms) if self.duration_ms is not None else 1000 + hex_values.append(str(duration)) + + return hex_values + + # -- Frame.from_rows override (for subclass construction only) --------------------------- + @classmethod + def from_rows( + cls, + id: int, + name: str, + position: int, + duration_ms: int, + rows: list[list[int]] | list[str], + brightness_levels: int = 256, + ) -> "AppFrame": + """Create an AppFrame from frontend 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. + + We delegate parsing/validation to Frame.from_rows and then construct an + AppFrame instance with subclass-specific attributes. + + Args: + rows (list | list[str]): frontend rows representation (list of lists or list of strings). + brightness_levels (int): number of brightness levels (default 256). + + Attributes: + id (int): database ID of the frame. + name (str): user-defined name of the frame. + position (int): user-defined position/order of the frame. + duration_ms (int): duration in milliseconds for animated frames. + + 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) + diff --git a/examples/led-matrix-painter/python/main.py b/examples/led-matrix-painter/python/main.py new file mode 100644 index 0000000..8e66316 --- /dev/null +++ b/examples/led-matrix-painter/python/main.py @@ -0,0 +1,338 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_bricks.web_ui import WebUI +from arduino.app_utils import App, Bridge, FrameDesigner, Logger +from app_frame import AppFrame # user module defining AppFrame +import store # user module for DB operations +import logging + +BRIGHTNESS_LEVELS = 8 # must match the frontend slider range (0..BRIGHTNESS_LEVELS-1) + +logger = Logger(__name__, level=logging.DEBUG) +ui = WebUI() +designer = FrameDesigner() + +logger.info("Initializing LED matrix tool") +store.init_db() +logger.info(f"Database initialized, brightness_levels={BRIGHTNESS_LEVELS}") + + +def get_config(): + """Expose runtime configuration for the frontend.""" + return { + 'brightness_levels': BRIGHTNESS_LEVELS, + 'width': designer.width, + 'height': designer.height, + } + + +def apply_frame_to_board(frame: AppFrame): + """Send frame bytes to the Arduino board.""" + frame_bytes = frame.to_board_bytes() + Bridge.call("draw", frame_bytes) + frame_label = f"name={frame.name}, id={frame.id if frame.id else 'None (preview)'}" + logger.debug(f"Frame sent to board: {frame_label}, bytes_len={len(frame_bytes)}") + + +def update_board(payload: dict): + """Update board display in real-time without persisting to DB. + + Used for live preview during editing. + Expected payload: {rows, name, id, position, duration_ms, brightness_levels} + """ + frame = AppFrame.from_json(payload) + apply_frame_to_board(frame) + vector_text = frame.to_c_string() + return {'ok': True, 'vector': vector_text} + + +def persist_frame(payload: dict): + """Persist frame to DB (insert new or update existing). + + Backend (store.save_frame) is responsible for assigning progressive names. + + Expected payload: {rows, name, id, position, duration_ms, brightness_levels} + """ + frame = AppFrame.from_json(payload) + + if frame.id is None: + # Insert new frame - backend assigns name if empty + logger.debug(f"Creating new frame: name='{frame.name}'") + frame.id = store.save_frame(frame) + # Reload frame to get backend-assigned name + record = store.get_frame_by_id(frame.id) + if record: + frame = AppFrame.from_record(record) + logger.info(f"New frame created: id={frame.id}, name={frame.name}") + else: + # Update existing frame + logger.debug(f"Updating frame: id={frame.id}, name={frame.name}") + store.update_frame(frame) + + apply_frame_to_board(frame) + vector_text = frame.to_c_string() + return {'ok': True, 'frame': frame.to_json(), 'vector': vector_text} + + +def bulk_update_frame_duration(payload) -> bool: + """Update the duration of all frames.""" + duration = payload.get('duration_ms', 1000) + logger.debug(f"Bulk updating frame duration: duration={duration}") + store.bulk_update_frame_duration(duration) + return True + + +def load_frame(payload: dict = None): + """Load a frame for editing or create empty if none exist. + + Optional payload: {id: int} to load specific frame + If no ID provided, loads last frame or creates empty + """ + fid = payload.get('id') if payload else None + + if fid is not None: + logger.debug(f"Loading frame by id: {fid}") + record = store.get_frame_by_id(fid) + if not record: + logger.warning(f"Frame not found: id={fid}") + return {'error': 'frame not found'} + frame = AppFrame.from_record(record) + logger.info(f"Frame loaded: id={frame.id}, name={frame.name}") + else: + # Get last frame or create empty + logger.debug("Loading last frame or creating empty") + frame = store.get_or_create_active_frame(brightness_levels=BRIGHTNESS_LEVELS) + logger.info(f"Active frame ready: id={frame.id}, name={frame.name}") + + apply_frame_to_board(frame) + vector_text = frame.to_c_string() + return {'ok': True, 'frame': frame.to_json(), 'vector': vector_text} + + +def list_frames(): + """Return list of frames for sidebar.""" + records = store.list_frames(order_by='position ASC, id ASC') + frames = [AppFrame.from_record(r).to_json() for r in records] + return {'frames': frames} + + +def get_frame(payload: dict): + """Get single frame by ID.""" + fid = payload.get('id') + record = store.get_frame_by_id(fid) + + if not record: + return {'error': 'not found'} + + frame = AppFrame.from_record(record) + return {'frame': frame.to_json()} + + +def delete_frame(payload: dict): + """Delete frame by ID.""" + fid = payload.get('id') + logger.info(f"Deleting frame: id={fid}") + store.delete_frame(fid) + return {'ok': True} + + +def reorder_frames(payload: dict): + """Reorder frames to match provided id list order.""" + order = payload.get('order', []) + logger.info(f"Reordering frames: new order={order}") + store.reorder_frames(order) + return {'ok': True} + + +def transform_frame(payload: dict): + """Apply transformation operation to a frame. + + Payload: {op: str, rows: list OR id: int} + Operations: invert, invert_not_null, rotate180, flip_h, flip_v + """ + op = payload.get('op') + if not op: + return {'error': 'op required'} + + # Load frame from rows or by ID + rows = payload.get('rows') + if rows is not None: + frame = AppFrame.from_json({'rows': rows, 'brightness_levels': BRIGHTNESS_LEVELS}) + logger.debug(f"Transforming frame from rows: op={op}") + else: + fid = payload.get('id') + if fid is None: + return {'error': 'id or rows required'} + record = store.get_frame_by_id(fid) + if not record: + return {'error': 'frame not found'} + frame = AppFrame.from_record(record) + logger.debug(f"Transforming frame by id: id={fid}, op={op}") + + # Apply transformation + operations = { + 'invert': designer.invert, + 'invert_not_null': designer.invert_not_null, + 'rotate180': designer.rotate180, + 'flip_h': designer.flip_horizontally, + 'flip_v': designer.flip_vertically, + } + if op not in operations: + logger.warning(f"Unsupported transform operation: {op}") + return {'error': 'unsupported op'} + + operations[op](frame) + logger.info(f"Transform applied: op={op}") + + # Return transformed frame (frontend will handle board update via persist) + return {'ok': True, 'frame': frame.to_json(), 'vector': frame.to_c_string()} + + +def export_frames(payload: dict = None): + """Export multiple frames into a single C header string. + + Payload (optional): {frames: [id,...], animations: [{name, frames}]} + - If no animations: exports frames as individual arrays (Frames mode) + - If animations present: exports as animation sequences (Animations mode) + """ + # Get frame IDs to export + if payload and payload.get('frames'): + frame_ids = [int(fid) for fid in payload['frames']] + logger.info(f"Exporting selected frames: ids={frame_ids}") + records = [store.get_frame_by_id(fid) for fid in frame_ids] + records = [r for r in records if r is not None] + else: + logger.info("Exporting all frames") + records = store.list_frames(order_by='position ASC, id ASC') + + logger.debug(f"Exporting {len(records)} frames to C header") + + # Build frame objects and check for duplicate names + frames = [AppFrame.from_record(r) for r in records] + frame_names = {} # name -> count + for frame in frames: + frame_names[frame.name] = frame_names.get(frame.name, 0) + 1 + + # Assign unique names if duplicates exist + name_counters = {} # name -> current index + for frame in frames: + if frame_names[frame.name] > 1: + # Duplicate detected, add suffix + if frame.name not in name_counters: + name_counters[frame.name] = 0 + # Use _idN suffix for uniqueness + frame._export_name = f"{frame.name}_id{frame.id}" + logger.debug(f"Duplicate name '{frame.name}' -> '{frame._export_name}'") + else: + # Unique name, use as-is + frame._export_name = frame.name + + # Check if we're in animations mode + animations = payload.get('animations') if payload else None + + if animations: + # Animation mode: export as animation sequences + logger.info(f"Animation mode: {len(animations)} animation(s)") + header_parts = [] + + for anim in animations: + anim_name = anim.get('name', 'Animation') + anim_frame_ids = anim.get('frames', []) + + # Get frames for this animation + anim_frames = [f for f in frames if f.id in anim_frame_ids] + + if not anim_frames: + continue + + # Build animation array + 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 = "\n".join(header_parts).strip() + "\n" + return {'header': header} + else: + # Frames mode: export individual frame arrays + header_parts = [] + for frame in frames: + header_parts.append(f"// {frame._export_name} (id {frame.id})") + header_parts.append(frame.to_c_string()) + + header = "\n".join(header_parts).strip() + "\n" + return {'header': header} + + +def play_animation(payload: dict): + """Play animation sequence on the board. + + Payload: {frames: [id,...], loop: bool} + - frames: list of frame IDs to play in sequence + - loop: whether to loop the animation (default: false) + """ + frame_ids = payload.get('frames', []) + loop = payload.get('loop', False) + + if not frame_ids: + logger.warning("play_animation called with no frames") + return {'error': 'no frames provided'} + + logger.info(f"Playing animation: frame_count={len(frame_ids)}, loop={loop}") + + # Load frames from DB + records = [store.get_frame_by_id(fid) for fid in frame_ids] + records = [r for r in records if r is not None] + + if not records: + logger.warning("No valid frames found for animation") + return {'error': 'no valid frames found'} + + frames = [AppFrame.from_record(r) for r in records] + 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() + # Convert hex strings to uint32_t integers, then to bytes + for hex_str in hex_values: + value = int(hex_str, 16) + # Pack as 4 bytes, little-endian + animation_bytes.extend(value.to_bytes(4, byteorder='little')) + + logger.debug(f"Animation data prepared: {len(animation_bytes)} bytes ({len(animation_bytes)//20} frames)") + + # Send to board via Bridge as bytes (not list) + # Bridge expects bytes object for std::vector + try: + Bridge.call("play_animation", bytes(animation_bytes)) + logger.info("Animation sent to board successfully") + return {'ok': True, 'frames_played': len(frames)} + except Exception as e: + logger.warning(f"Failed to send animation to board: {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) +ui.expose_api('GET', '/list_frames', list_frames) +ui.expose_api('POST', '/get_frame', get_frame) +ui.expose_api('POST', '/delete_frame', delete_frame) +ui.expose_api('POST', '/transform_frame', transform_frame) +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('GET', '/config', get_config) + +App.run() diff --git a/examples/led-matrix-painter/python/store.py b/examples/led-matrix-painter/python/store.py new file mode 100644 index 0000000..8858b5b --- /dev/null +++ b/examples/led-matrix-painter/python/store.py @@ -0,0 +1,208 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_bricks.dbstorage_sqlstore import SQLStore +from app_frame import AppFrame +from typing import Any + +DB_NAME = "led_matrix_frames" + +# Initialize and expose a module-level SQLStore instance +db = SQLStore(database_name=DB_NAME) + + +def init_db(): + """Start SQLStore and create the frames table. + + Call this from the application startup (it is intentionally + separated from module import so the application controls lifecycle). + """ + db.start() + db.create_table( + "frames", + { + "id": "INTEGER PRIMARY KEY", + "name": "TEXT", + "duration_ms": "INTEGER", + "position": "INTEGER", + "brightness_levels": "INTEGER", + "rows": "TEXT", # JSON string encoding of 2D array + } + ) + print("[db_frames] SQLStore started for frames persistence") + + +def list_frames(order_by: str = "position ASC, id ASC") -> list[dict[str, Any]]: + """Return ordered list of frame records (raw DB dicts). + + Returns: + list[dict]: list of frame records with all fields + """ + res = db.read("frames", order_by=order_by) or [] + return res + + +def get_frame_by_id(fid: int) -> dict[str, Any] | None: + """Return the raw DB record dict for a frame id. + + Args: + fid (int): frame id + + Returns: + dict | None: raw DB record dict or None if not found + """ + res = db.read("frames", condition=f"id = {int(fid)}") or [] + if not res: + return None + return res[0] + + +def save_frame(frame: AppFrame) -> int: + """Insert a new frame into DB and return assigned ID. + + Backend is responsible for assigning progressive names if name is empty. + + Args: + frame (AppFrame): frame to save (id will be ignored and assigned by DB) + + Returns: + int: newly assigned frame ID + """ + # Calculate next position + mx_rows = db.read("frames", columns=["MAX(position) as maxpos"]) or [] + maxpos = mx_rows[0].get("maxpos") if mx_rows and len(mx_rows) > 0 else None + next_position = (int(maxpos) if maxpos is not None else 0) + 1 + + # Use frame.position if set, otherwise use next_position + position = frame.position if frame.position is not None else next_position + + record = frame.to_record() + record['position'] = position + # Remove id from record (will be auto-assigned) + record.pop('id', None) + + db.store("frames", record, create_table=False) + + last = db.execute_sql("SELECT last_insert_rowid() as id") + new_id = last[0].get("id") if last else None + + # Backend responsibility: assign progressive name if empty + if new_id and (not frame.name or frame.name.strip() == ''): + frame.name = f'Frame{new_id}' + frame.id = new_id + db.update("frames", {"name": frame.name}, condition=f"id = {new_id}") + + return new_id + + +def update_frame(frame: AppFrame) -> bool: + """Update an existing frame in DB. + + Args: + frame (AppFrame): frame to update (must have valid id) + + Returns: + bool: True if update succeeded + """ + if frame.id is None: + raise ValueError("Cannot update frame without id") + + record = frame.to_record() + # Remove id from update dict (used in WHERE clause) + fid = record.pop('id') + + db.update("frames", record, condition=f"id = {int(fid)}") + return True + + +def bulk_update_frame_duration(duration) -> bool: + """Update the duration of all frames. + + Args: + duration (int): new duration in milliseconds + + Returns: + bool: True if update succeeded + """ + if duration < 1: + raise ValueError("Valid duration must be provided for bulk update") + db.update("frames", {"duration_ms": int(duration)}) + return True + +def delete_frame(fid: int) -> bool: + """Delete a frame and recompact positions. + + Args: + fid (int): frame id to delete + + Returns: + bool: True if deletion succeeded + """ + db.delete("frames", condition=f"id = {int(fid)}") + # Recompact positions + rows = db.read("frames", order_by="position ASC, id ASC") or [] + for pos, r in enumerate(rows, start=1): + db.update("frames", {"position": pos}, condition=f"id = {int(r.get('id'))}") + return True + + +def reorder_frames(order: list[int]) -> bool: + """Reorder frames by assigning new positions based on provided ID list. + + Args: + order (list[int]): list of frame IDs in desired order + + Returns: + bool: True if reorder succeeded + """ + for idx, fid in enumerate(order, start=1): + db.update("frames", {"position": idx}, condition=f"id = {int(fid)}") + return True + + +def get_last_frame() -> AppFrame | None: + """Get the last frame (highest position) or None if no frames exist. + + Returns: + AppFrame | None: last frame or None + """ + records = db.read("frames", order_by="position DESC, id DESC") or [] + if not records: + return None + return AppFrame.from_record(records[0]) + + +def get_or_create_active_frame(brightness_levels: int = 8) -> AppFrame: + """Get last frame or create empty frame if none exist. + + Backend is responsible for assigning progressive names via save_frame(). + + Args: + brightness_levels (int): brightness levels for new frame (default 8) + + Returns: + AppFrame: last existing frame or newly created empty frame + """ + last = get_last_frame() + if last is not None: + return last + + # Create empty frame with empty name (backend will assign Frame{id}) + frame = AppFrame.create_empty( + id=None, + name="", + position=1, + duration_ms=1000, + brightness_levels=brightness_levels + ) + + # Backend assigns ID and name automatically + frame.id = save_frame(frame) + + # Reload from DB to get the assigned name + record = get_frame_by_id(frame.id) + if record: + return AppFrame.from_record(record) + + return frame diff --git a/examples/led-matrix-painter/sketch/sketch.ino b/examples/led-matrix-painter/sketch/sketch.ino new file mode 100644 index 0000000..7f54dfb --- /dev/null +++ b/examples/led-matrix-painter/sketch/sketch.ino @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +// +// SPDX-License-Identifier: MPL-2.0 + +// Example sketch using Arduino_LED_Matrix and RouterBridge. This sketch +// exposes two providers: +// - "draw" which accepts a std::vector (by-value) and calls matrix.draw() +// - "play_animation" which accepts a byte array representing multiple frames +#include +#include +#include + +Arduino_LED_Matrix matrix; + +void draw(std::vector frame) { + if (frame.empty()) { + Serial.println("[sketch] draw called with empty frame"); + return; + } + Serial.print("[sketch] draw called, frame.size="); + Serial.println((int)frame.size()); + matrix.draw(frame.data()); +} + +// Play animation using std::vector to avoid C++ exception linking issues +// The data is sent as bytes from Python: each uint32_t is sent as 4 bytes (little-endian) +void play_animation(std::vector animation_bytes) { + if (animation_bytes.empty()) { + Serial.println("[sketch] play_animation called with empty data"); + return; + } + + // Each uint32_t is 4 bytes, each frame is 5 uint32_t (20 bytes) + const int BYTES_PER_FRAME = 20; + int frame_count = animation_bytes.size() / BYTES_PER_FRAME; + + Serial.print("[sketch] play_animation called, bytes="); + Serial.print((int)animation_bytes.size()); + Serial.print(", frame_count="); + Serial.println(frame_count); + + if (frame_count == 0) { + Serial.println("[sketch] Invalid animation data: not enough bytes"); + return; + } + + // Maximum 50 frames to avoid stack overflow + const int MAX_FRAMES = 50; + if (frame_count > MAX_FRAMES) { + Serial.print("[sketch] Too many frames, truncating to "); + Serial.println(MAX_FRAMES); + frame_count = MAX_FRAMES; + } + + // Static buffer to avoid dynamic allocation + static uint32_t animation[MAX_FRAMES][5]; + + // Convert bytes to uint32_t array + const uint8_t* data = animation_bytes.data(); + for (int i = 0; i < frame_count; 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); + } + } + + // 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); + matrix.clear(); + + Bridge.begin(); + + // Register the draw provider (by-value parameter). Using by-value avoids + // RPC wrapper template issues with const reference params. + Bridge.provide("draw", draw); + + // Register the animation player provider + Bridge.provide("play_animation", play_animation); +} + +void loop() { + delay(200); +} diff --git a/examples/led-matrix-painter/sketch/sketch.yaml b/examples/led-matrix-painter/sketch/sketch.yaml new file mode 100644 index 0000000..d9fe917 --- /dev/null +++ b/examples/led-matrix-painter/sketch/sketch.yaml @@ -0,0 +1,11 @@ +profiles: + default: + fqbn: arduino:zephyr:unoq + platforms: + - platform: arduino:zephyr + libraries: + - MsgPack (0.4.2) + - DebugLog (0.8.4) + - ArxContainer (0.7.0) + - ArxTypeTraits (0.3.1) +default_profile: default