A tiny framework for turning ESP32 boards into USB-driven canvas displays
  • Rust 42.7%
  • TypeScript 27.9%
  • C 16.3%
  • Python 7.4%
  • JavaScript 4.7%
  • Other 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Martin M. 7262740c59 Rewrite the README around any ESP32 board
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.
2026-07-27 21:34:13 +02:00
docs Rewrite the README around any ESP32 board 2026-07-27 21:34:13 +02:00
firmware Add a Rust client library 2026-07-27 21:13:44 +02:00
rust Add a Rust client library 2026-07-27 21:13:44 +02:00
ts Prepare for publishing as espframe 2026-07-27 20:30:57 +02:00
.editorconfig Canvas-over-USB display driver for the ESP32-C6-LCD-1.47 2026-07-27 19:08:04 +02:00
.gitignore Ignore local agent tooling 2026-07-27 21:33:32 +02:00
LICENSE Prepare for publishing as espframe 2026-07-27 20:30:57 +02:00
README.md Rewrite the README around any ESP32 board 2026-07-27 21:34:13 +02:00

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.