- Rust 42.7%
- TypeScript 27.9%
- C 16.3%
- Python 7.4%
- JavaScript 4.7%
- Other 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The README led with the Waveshare ESP32-C6-LCD-1.47 and ran to 306 lines of design rationale that docs/ already covers. Cut it to the essentials and frame the project as a general ESP32 canvas-over-USB driver, with the C6-LCD-1.47 as the tested target rather than the only one. Adds a porting table: pins and geometry are Kconfig, the panel controller is one esp_lcd call, the chip is idf.py set-target. Moves the Rust serial write-timeout hazard into docs/hardware.md, which is the only removed content that was not already documented elsewhere. |
||
| docs | ||
| firmware | ||
| rust | ||
| ts | ||
| .editorconfig | ||
| .gitignore | ||
| LICENSE | ||
| README.md | ||
espframe
Turn an ESP32 board with an SPI display into an external canvas you draw on over USB — from
TypeScript with a real CanvasRenderingContext2D, or from Rust with a plain RGBA buffer.
import { openDevice } from 'espframe/node';
const display = await openDevice(); // finds the board on USB CDC
display.run((ctx) => { // ctx is a real CanvasRenderingContext2D
ctx.fillStyle = '#0b0d12';
ctx.fillRect(0, 0, display.width, display.height);
ctx.fillStyle = '#e6edf3';
ctx.font = '28px sans-serif';
ctx.fillText(new Date().toLocaleTimeString(), 12, 160);
}); // diff, encode, and pacing handled for you
use espframe::{open_device, rgb};
let mut display = open_device()?;
display.run(|surface, ctx| { // surface is a plain RGBA buffer
surface.clear(rgb(11, 13, 18));
let y = 160.0 + (ctx.t * 2.0).sin() * 80.0;
surface.fill_rect(62, y as i32 - 24, 48, 48, rgb(76, 154, 255));
})?;
Why this isn't just "send pixels"
USB Serial/JTAG carries about 125 kB/s. A 172×320 RGB565 frame is 107 KiB, so uncompressed full frames run at 1.15 FPS — while the panel itself could accept 45. The link is roughly 40× slower than the display, which makes compression mandatory rather than an optimisation.
espframe closes the gap with dirty rectangles and RLE, then locks the frame interval to an integer multiple of the device's present tick. Measured end to end on hardware: 29.95 FPS at σ = 0.00 ms on UI-style content. Photographic full-screen content doesn't compress and stays near 1 FPS — a hardware limit, not a protocol one.
The design goal is not peak FPS but even intervals: pick a rate the link can always meet, then
never miss it. docs/architecture.md explains how.
Install
npm install espframe
npm install serialport @napi-rs/canvas # optional peers: Node transport + canvas backend
[dependencies]
espframe = "0.1" # default-features = false drops the serialport dependency
Firmware needs ESP-IDF v5.3+:
cd firmware
idf.py set-target esp32c6
idf.py build
idf.py -p COM<N> flash monitor # monitor stays silent, the console is disabled on purpose
Hardware support
Tested on the Waveshare ESP32-C6-LCD-1.47 (ESP32-C6, 172×320 ST7789). Nothing above the firmware is board-specific — the protocol and both host libraries only know the width, height, and buffer count the device reports at handshake.
To port it:
| Change | What to do |
|---|---|
| Different pins, panel size, rotation, TE GPIO | idf.py menuconfig — all of it is Kconfig |
| Different panel controller | Swap the esp_lcd_new_panel_st7789() call in firmware/main/display.c |
| Different ESP32 with USB Serial/JTAG (C3, S3, C6, H2, P4) | idf.py set-target |
The double buffer needs ~215 KiB of internal DMA-capable SRAM at 172×320, which scales with panel area. That budget is what rules out using WiFi or Bluetooth alongside it.
Layout
| Path | What |
|---|---|
firmware/ |
ESP-IDF project: USB CDC framing, decode, double buffer, present tick |
ts/ |
npm package, Node + browser (Web Serial) |
rust/ |
cargo crate, blocking and single-threaded, no async runtime |
docs/ |
protocol (normative), architecture, hardware |
If you write your own client, read docs/protocol.md along with the
host-side hazards in docs/hardware.md. Opening the serial port can
silently reboot the board, and each trap documented there cost real debugging time.
Non-goals
WiFi, Bluetooth, touch, and SD playback are all out of scope. The device always presents whole frames — dirty regions are a transport optimisation, never partial presentation.
License
MIT — see LICENSE.