Migrate to Waveshare ESP32-S3-Touch-AMOLED-2.16 with auto-rotation, splash, and battery

Full hardware swap from Panlee SC01 Plus (480×320 IPS) to Waveshare 2.16"
square AMOLED (480×480, CO5300 + CST9220 + AXP2101 + QMI8658). Library
stack moves to Arduino_GFX, SensorLib, XPowersLib on the pioarduino
platform 55.03.38-1 (Arduino Core 3.x).

UI:
- 4 screens (splash, usage, controller, bluetooth) with 3-button physical
  navigation: GPIO 0 = prev, AXP PKEY = cycle, GPIO 18 = next.
- IMU-driven 90° auto-rotation. CO5300 can't rotate via MADCTL, so flush
  callback does CPU strip-rotation in PARTIAL render mode. Rotation
  transitions use AMOLED brightness flash (instant black → redraw → ramp).
- Battery indicator (Lucide icons) in upper-right, RGB565A8 alpha so it
  blends over the splash animations.
- USB plugged/unplugged auto-switches between Usage and Controller
  screens (suppressed while on splash).
- Fonts and icons re-scaled ~1.9× for the higher-DPI panel; 20px margins
  to clear rounded corners.

Splash:
- 13 × 20×20 pixel-art creature animations sourced from
  claudepix.vercel.app via tools/scrape_claudepix.js (scraper handles
  both PRESET creature-engine and standalone FRAMES+PAL formats).
  tools/convert_to_c.js emits firmware/src/splash_animations.h.
  Attribution preserved in README and the generated header.

Tooling:
- tools/png_to_lvgl.js converts alpha PNGs to LVGL RGB565A8 (planar
  layout, with --tint to colorize Lucide black-on-transparent sources).
- tools/scrape_claudepix.js, tools/convert_to_c.js for the splash
  pipeline.

Docs:
- New worktree CLAUDE.md with hardware pin map, file inventory, build
  commands, and the 8 critical gotchas (CO5300 rotation, OPI PSRAM,
  pioarduino requirement, LVGL 9 font patching, centralized touch read,
  even-aligned flush regions, touch swap/mirror values, RGB565A8 layout).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hermann Björgvin Haraldsson
2026-05-09 22:04:04 +00:00
co-authored by Claude Opus 4.7
parent 10b8052bcc
commit 97a5443601
65 changed files with 121542 additions and 801 deletions
+53
View File
@@ -0,0 +1,53 @@
# Splash animation tools
Two-step pipeline for getting third-party 20×20 pixel animations onto the device.
## 1. Scrape
```bash
node scrape_claudepix.js
```
Fetches the manifest from `claudepix.vercel.app/app.js`, then each animation's
HTML file, evaluates the embedded JS in a Node VM context (loading the same
`creature-engine.js` the site uses), and writes resolved frame data to
`tools/claudepix_data/*.json`.
Each output file looks like:
```json
{
"filename": "idle_breathe.html",
"name": "idle breathe",
"category": "Idle",
"description": "...",
"frame_count": 17,
"frames": [{ "hold": 500, "grid": [[0,0,...],[0,1,1,...],...] }, ...]
}
```
Override URL or output dir with `--base` and `--out`.
## 2. Convert to C
```bash
node convert_to_c.js
```
Reads `tools/claudepix_data/*.json` and emits a single
`firmware/src/splash_animations.h` with:
- `splash_<ident>_frames[N][400]` — per-frame cell codes (0 = empty, 1 = body, 2 = eye)
- `splash_<ident>_holds[N]` — per-frame hold time in ms
- `splash_anims[]` — master table with name, category, frame count, pointers
- `SPLASH_ANIM_COUNT`
The firmware (`splash.cpp`) consumes this header to render and animate.
## Re-running
The scraper is idempotent — re-run any time the source library updates. The
converter overwrites the header. Rebuild firmware after running both.
## License note
The scraper hits a public site without a stated license. Confirm reuse is
appropriate for your case before redistributing the output.
+93
View File
@@ -0,0 +1,93 @@
[
{
"filename": "idle_breathe.html",
"name": "idle breathe",
"category": "Idle",
"frame_count": 16,
"palette_size": 3
},
{
"filename": "idle_blink.html",
"name": "idle blink",
"category": "Idle",
"frame_count": 16,
"palette_size": 3
},
{
"filename": "idle_look_around.html",
"name": "idle look around",
"category": "Idle",
"frame_count": 17,
"palette_size": 3
},
{
"filename": "expression_wink.html",
"name": "expression wink",
"category": "Expressions",
"frame_count": 12,
"palette_size": 3
},
{
"filename": "expression_surprise.html",
"name": "expression surprise",
"category": "Expressions",
"frame_count": 12,
"palette_size": 3
},
{
"filename": "expression_sleep.html",
"name": "expression sleep",
"category": "Expressions",
"frame_count": 24,
"palette_size": 3
},
{
"filename": "dance_bounce.html",
"name": "dance bounce",
"category": "Dance",
"frame_count": 16,
"palette_size": 3
},
{
"filename": "dance_sway.html",
"name": "dance sway",
"category": "Dance",
"frame_count": 12,
"palette_size": 3
},
{
"filename": "work_coding.html",
"name": "work coding",
"category": "Work",
"frame_count": 23,
"palette_size": 10
},
{
"filename": "work_think.html",
"name": "work think",
"category": "Work",
"frame_count": 24,
"palette_size": 3
},
{
"filename": "dance_bounce_dj.html",
"name": "dance bounce dj",
"category": "Dance",
"frame_count": 16,
"palette_size": 10
},
{
"filename": "dance_sway_dj.html",
"name": "dance sway dj",
"category": "Dance",
"frame_count": 12,
"palette_size": 10
},
{
"filename": "dance_djmix.html",
"name": "dance djmix",
"category": "Dance",
"frame_count": 16,
"palette_size": 10
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Converts scraped JSON animation data to firmware/src/splash_animations.h.
*
* Per-animation palette (up to 10 entries) is converted to RGB565. Cells in
* each frame are palette indices (0..9). Splash module looks up colors via
* palette[cell].
*
* Usage: node convert_to_c.js [--in DIR] [--out FILE]
*/
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
const opt = (k, def) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : def; };
const IN_DIR = path.resolve(opt('--in', path.join(__dirname, 'claudepix_data')));
const OUT_FILE = path.resolve(opt('--out',
path.join(__dirname, '..', 'firmware', 'src', 'splash_animations.h')));
const PALETTE_SIZE = 10;
function safeIdent(s) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
function hexToRgb565(hex) {
if (!hex || hex === 'transparent') return 0x10A2; // dark bg
let h = hex.replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const r = parseInt(h.substr(0, 2), 16);
const g = parseInt(h.substr(2, 2), 16);
const b = parseInt(h.substr(4, 2), 16);
return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3);
}
function paletteToRgb565(palette) {
const out = new Array(PALETTE_SIZE).fill(0x10A2);
for (let i = 0; i < palette.length && i < PALETTE_SIZE; i++) {
out[i] = hexToRgb565(palette[i]);
}
return out;
}
function main() {
if (!fs.existsSync(IN_DIR)) {
console.error(`No scraped data at ${IN_DIR}. Run scrape_claudepix.js first.`);
process.exit(1);
}
const indexPath = path.join(IN_DIR, '_index.json');
if (!fs.existsSync(indexPath)) {
console.error(`Missing ${indexPath}.`);
process.exit(1);
}
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
console.log(`Converting ${index.length} animations`);
let out = '';
out += '// ============================================================\n';
out += '// Splash animations — generated by tools/convert_to_c.js.\n';
out += '// Source: https://claudepix.vercel.app (20x20 pixel-art creature\n';
out += '// animation library). Frames extracted by tools/scrape_claudepix.js\n';
out += '// from per-animation HTML files served by the source site.\n';
out += '// Do not edit by hand — re-run the scraper + converter to refresh.\n';
out += '// ============================================================\n';
out += '// Each animation carries a 10-entry RGB565 palette.\n';
out += '// Cell values 0..9 index into palette.\n';
out += '#pragma once\n#include <stdint.h>\n\n';
out += `#define SPLASH_PALETTE_SIZE ${PALETTE_SIZE}\n\n`;
out += 'typedef struct {\n';
out += ' const char *name;\n';
out += ' const char *category;\n';
out += ' uint16_t frame_count;\n';
out += ' const uint16_t *palette;\n';
out += ' const uint8_t (*frames)[400];\n';
out += ' const uint16_t *holds;\n';
out += '} splash_anim_def_t;\n\n';
const entries = [];
for (const meta of index) {
const ident = safeIdent(meta.filename.replace(/\.html?$/, ''));
const dataPath = path.join(IN_DIR, meta.filename.replace(/\.html?$/, '.json'));
const data = JSON.parse(fs.readFileSync(dataPath, 'utf8'));
const pal565 = paletteToRgb565(data.palette);
out += `static const uint16_t splash_${ident}_palette[${PALETTE_SIZE}] = {`;
out += pal565.map(c => `0x${c.toString(16).toUpperCase().padStart(4, '0')}`).join(',');
out += '};\n';
out += `static const uint8_t splash_${ident}_frames[${data.frames.length}][400] = {\n`;
for (const f of data.frames) {
const flat = [];
for (let r = 0; r < 20; r++)
for (let c = 0; c < 20; c++)
flat.push(f.grid[r][c]);
out += ' {' + flat.join(',') + '},\n';
}
out += '};\n';
out += `static const uint16_t splash_${ident}_holds[${data.frames.length}] = {`;
out += data.frames.map(f => f.hold).join(',');
out += '};\n\n';
entries.push({ ident, name: data.name, category: data.category, count: data.frames.length });
}
out += `#define SPLASH_ANIM_COUNT ${entries.length}\n`;
out += 'static const splash_anim_def_t splash_anims[SPLASH_ANIM_COUNT] = {\n';
for (const e of entries) {
out += ` {"${e.name}", "${e.category}", ${e.count}, splash_${e.ident}_palette, splash_${e.ident}_frames, splash_${e.ident}_holds},\n`;
}
out += '};\n';
fs.writeFileSync(OUT_FILE, out);
console.log(`Wrote ${OUT_FILE} (${entries.length} animations, ${(out.length / 1024).toFixed(1)} KB)`);
}
main();
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env node
// Convert a PNG with alpha channel to an LVGL RGB565A8 C array.
// Layout: w*h RGB565 pixels (little-endian) followed by w*h alpha bytes.
// Usage: node png_to_lvgl.js <input.png> <symbol_name> [W_MACRO] [H_MACRO] [--tint=RRGGBB]
// Default tint=FFFFFF — Lucide PNGs are black-on-transparent, so without a tint
// the icon would render as invisible black; white is the typical choice for UI overlays.
const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
function rgb565(r, g, b) {
return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}
function convert(pngPath, symbol, wMacro, hMacro, tint) {
const buf = fs.readFileSync(pngPath);
const png = PNG.sync.read(buf);
const w = png.width, h = png.height;
const total = w * h;
const colorBytes = Buffer.alloc(total * 2);
const alphaBytes = Buffer.alloc(total);
const tr = tint ? (tint >> 16) & 0xFF : null;
const tg = tint ? (tint >> 8) & 0xFF : null;
const tb = tint ? tint & 0xFF : null;
for (let i = 0; i < total; i++) {
const r = tint !== null ? tr : png.data[i * 4 + 0];
const g = tint !== null ? tg : png.data[i * 4 + 1];
const b = tint !== null ? tb : png.data[i * 4 + 2];
const a = png.data[i * 4 + 3];
const c = rgb565(r, g, b);
colorBytes[i * 2 + 0] = c & 0xFF;
colorBytes[i * 2 + 1] = (c >> 8) & 0xFF;
alphaBytes[i] = a;
}
const all = Buffer.concat([colorBytes, alphaBytes]);
let out = `#define ${wMacro} ${w}\n#define ${hMacro} ${h}\n`;
out += `// RGB565A8: ${total} RGB565 pixels (little-endian) followed by ${total} alpha bytes\n`;
out += `static const uint8_t ${symbol}[${all.length}] = {\n `;
const lines = [];
for (let i = 0; i < all.length; i += 16) {
const row = [];
for (let j = 0; j < 16 && i + j < all.length; j++) {
row.push('0x' + all[i + j].toString(16).padStart(2, '0').toUpperCase());
}
lines.push(row.join(', '));
}
out += lines.join(',\n ');
out += '\n};\n';
return out;
}
if (require.main === module) {
const args = process.argv.slice(2);
let tint = 0xFFFFFF;
const positional = [];
for (const a of args) {
if (a.startsWith('--tint=')) tint = parseInt(a.slice(7), 16);
else if (a === '--no-tint') tint = null;
else positional.push(a);
}
const [pngPath, symbol, wMacro, hMacro] = positional;
if (!pngPath || !symbol) {
console.error('Usage: node png_to_lvgl.js <input.png> <symbol_name> [W_MACRO] [H_MACRO] [--tint=RRGGBB | --no-tint]');
process.exit(1);
}
const base = symbol.toUpperCase().replace(/_DATA$/, '');
process.stdout.write(convert(pngPath, symbol, wMacro || `${base}_W`, hMacro || `${base}_H`, tint));
}
module.exports = { convert, rgb565 };
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* Scrapes animations from claudepix.vercel.app.
* Handles two on-site formats:
* 1) PRESET via creature-engine.js (idle_*, expression_*, work_*, dance_bounce, dance_sway)
* 2) Standalone with window.FRAMES + window.PAL (dance_*_dj, dance_djmix)
*
* Output: tools/claudepix_data/<name>.json with shape:
* { filename, name, category, description, palette: ['#RRGGBB',...],
* frame_count, frames: [{ hold, grid: number[20][20] }] }
*
* Usage: node scrape_claudepix.js [--base URL] [--out DIR]
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const args = process.argv.slice(2);
const opt = (k, def) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : def; };
const BASE = opt('--base', 'https://claudepix.vercel.app');
const OUT_DIR = path.resolve(opt('--out', path.join(__dirname, 'claudepix_data')));
async function fetchText(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} fetching ${url}`);
return res.text();
}
// Build a sandbox that stubs the DOM so animations that touch it don't crash.
function makeSandbox() {
const fakeStyle = new Proxy({}, { get: () => '', set: () => true });
const fakeEl = {
style: fakeStyle,
appendChild: () => fakeEl,
addEventListener: () => {},
innerHTML: '',
children: [],
};
const sandbox = {
window: {},
document: {
getElementById: () => fakeEl,
createElement: () => ({ ...fakeEl, style: new Proxy({}, { get: () => '', set: () => true }) }),
},
requestAnimationFrame: () => 0,
cancelAnimationFrame: () => {},
performance: { now: () => 0 },
console,
};
sandbox.window.addEventListener = () => {};
return sandbox;
}
// Default creature-engine palette (CSS colors used by mount())
const ENGINE_PALETTE = ['transparent', '#CD7F6A', '#0f0f0f'];
// Derive category from filename prefix (matches site logic)
function deriveCategory(filename) {
const base = filename.replace(/\.html?$/i, '');
const prefix = base.split('_')[0];
if (!base.includes('_')) return 'Idle';
const aliases = { idle: 'Idle', expression: 'Expressions', dance: 'Dance', work: 'Work', code: 'Coding', coding: 'Coding' };
return aliases[prefix] || (prefix.charAt(0).toUpperCase() + prefix.slice(1));
}
function displayName(filename) {
return filename.replace(/\.html?$/i, '').replace(/_/g, ' ');
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
console.log(`Fetching manifest from ${BASE}/app.js`);
const appJs = await fetchText(`${BASE}/app.js`);
const manifestMatch = appJs.match(/const\s+MANIFEST\s*=\s*(\[[^\]]+\])/);
if (!manifestMatch) throw new Error('MANIFEST not found in app.js');
const manifest = eval(manifestMatch[1]);
console.log(`Found ${manifest.length} animations`);
console.log(`Fetching engine`);
const engineJs = await fetchText(`${BASE}/animations/creature-engine.js`);
const results = [];
for (const filename of manifest) {
process.stdout.write(` ${filename} ... `);
let html;
try { html = await fetchText(`${BASE}/animations/${filename}`); }
catch (e) { console.log(`FETCH ERR: ${e.message}`); continue; }
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)]
.map(m => m[1]).filter(s => s.trim().length > 50);
if (scripts.length === 0) { console.log('SKIP (no scripts)'); continue; }
const animScript = scripts[scripts.length - 1];
const sandbox = makeSandbox();
vm.createContext(sandbox);
const isStandalone = animScript.includes('STANDALONE') || animScript.includes('window.FRAMES');
try {
if (!isStandalone) {
vm.runInContext(engineJs, sandbox);
sandbox.window.PixelEngine.mount = () => ({});
}
vm.runInContext(animScript, sandbox);
} catch (e) {
console.log(`ERR: ${e.message}`);
continue;
}
let resolved, palette, name, category, description;
if (sandbox.window.PRESET) {
const preset = sandbox.window.PRESET;
const CREATURE = sandbox.window.PixelEngine.CREATURE;
resolved = preset.frames.map(f => ({
hold: f.hold,
grid: f.frame ? f.frame : CREATURE,
}));
palette = ENGINE_PALETTE;
name = preset.name;
category = preset.category;
description = preset.description || '';
} else if (sandbox.window.FRAMES) {
resolved = sandbox.window.FRAMES.map(f => ({ hold: f.hold, grid: f.frame }));
palette = sandbox.window.PAL || ENGINE_PALETTE;
// Standalone files don't expose PRESET metadata — derive from filename
name = displayName(filename);
category = deriveCategory(filename);
description = '';
} else {
console.log('SKIP (no PRESET or FRAMES)');
continue;
}
const out = {
filename,
name,
category,
description,
palette,
frame_count: resolved.length,
frames: resolved,
};
const outPath = path.join(OUT_DIR, filename.replace(/\.html?$/, '.json'));
fs.writeFileSync(outPath, JSON.stringify(out, null, 2));
console.log(`${resolved.length} frames, ${palette.length}-color palette`);
results.push(out);
}
fs.writeFileSync(
path.join(OUT_DIR, '_index.json'),
JSON.stringify(results.map(r => ({
filename: r.filename, name: r.name, category: r.category,
frame_count: r.frame_count, palette_size: r.palette.length,
})), null, 2)
);
console.log(`\nDone. ${results.length}/${manifest.length} animations saved.`);
}
main().catch(e => { console.error(e); process.exit(1); });