2025 · Microcontroller · Espressif · M5Stack

Espressif ESP32-P4

Rendered by damage: an RGB565 raster path and a PPA hardware backend at 1280×720. QuickJS in 304 KB.

Renderer integrationNative rendererhosts/esp32p4/

The ESP32-P4 is Espressif's high-performance MCU: two RISC-V cores at up to 400 MHz, a 40 MHz low-power core, 768 KB of on-chip SRAM, a Pixel Processing Accelerator, 2D-DMA, MIPI-DSI and CSI, and an H.264 encoder — Wi-Fi comes from a companion chip. M5Stack's Tab5 puts it behind a 5-inch 1280×720 IPS touchscreen with 32 MB of PSRAM and an ESP32-C6 for Wi-Fi 6.

PocketJS meets it twice. engine/backends/esp32p4-ppa is a no_std Rust renderer that interprets DrawLists, batches A8 coverage and hands FILL, BLEND and SRM transactions to the PPA with an ordered RGB565 software fallback — contributed by @HalfSweet and hardware-verified on a Tab5 at 1280×720. And Pocket Pi runs the complete Pi coding agent on the chip: QuickJS in a 304 KB profile, with files, schedules and hot-pluggable apps on board.

Processor
RISC-V ×2 · 400 MHz
Memory
768 KB + 32 MB PSRAM
Display
1280 × 720 (Tab5)
Close-up of an Espressif ESP32-P4 SoC on a small development board
Close-up of an Espressif ESP32-P4 SoC on a small development board. Photo: Pathfinbird · CC BY-SA 4.0 · The SoC on a development board; no M5Stack Tab5 photo is available under a free licence.
01

Hardware

curated here · sources below

ESP32-P4 SoC

CPU
Dual-core 32-bit RISC-V HP system, up to 400 MHz (360 MHz on the Tab5's ESP32-P4NRW32) plus a single-core 40 MHz LP RISC-V
SRAM
768 KB on-chip L2 + 8 KB TCM; 32 MB octal PSRAM in-package on the Tab5
Graphics
Pixel Processing Accelerator (fill, blend, scale-rotate-mirror), 2D-DMA, MIPI-DSI 2-lane, MIPI-CSI, H.264 encoder
Wireless
None on-chip; Tab5 pairs an ESP32-C6-MINI-1U for Wi-Fi 6 / BLE / Thread

M5Stack Tab5 (reference board)

Display
5″ IPS TFT, 1280 × 720, MIPI-DSI, capacitive touch (GT911 / ST7123 / ST7121)
Memory
16 MB flash, 32 MB PSRAM, microSD
Camera
SC2356 2 MP over MIPI-CSI
Audio
ES8388 codec, ES7210 AEC with dual microphones, 1 W speaker, 3.5 mm
I/O
USB-C OTG, USB-A host, RS-485, Grove, M5-Bus; BMI270 IMU; RX8130CE RTC
Power
NP-F550 7.4 V 2000 mAh (Kit); about 6 hours
Body
128 × 80 × 12 mm, 118 g; released 9 May 2025
02

PocketJS on this machine

Native renderer

What runs

hosts/esp32p4/components/pocketjs_ppa is the reusable ESP-IDF half: it registers one client each for FILL, BLEND and SRM and executes blocking transactions, while the product BSP owns display initialisation, presentation buffers and vblank scheduling. The Rust crate is enabled with its esp-idf feature and driven by one EspIdfPpaOps on the rendering task. The adapter is build-tested with ESP-IDF release/v6.0 and v6.1; it is a backend, not a stock application target.

What is proven

Reusable RGB565/PPA backend with a strip-equals-full parity suite and an ESP-IDF component smoke build; hardware-verified on an M5Stack Tab5 at 1280×720. Pocket Pi ships the QuickJS guest on the chip in a separate repository.

Record

  1. v0.8.0

    ESP32-P4, rendered by damage: RGB565 raster path and hybrid PPA backend, hardware-verified on an M5Stack Tab5 at 1280×720.

  2. Pocket Pi ports the Pi coding agent to the ESP32-P4 — QuickJS in a 304 KB profile.

  3. Hot-pluggable .pocketapp packages arrive over the network or UART on a running Pocket Pi.

Profile · as declared by the demo manifestsource ↗
Profile
esp32p4 (renderer integration)

Acquisition reports

04

Example code

upstream source · highlighted at build time

A Rust ESP-IDF example against the engine crates.

//! data-smoke — on-device conformance check for the data modules.
//!
//! Runs pocket-fs and pocket-db (no `mount` feature — the module cores
//! directly, the way a device host with its own guest wiring drives them)
//! against a LittleFS partition on real hardware, and reports over UART:
//!
//!   DATA-SMOKE: PASS boot=<n>
//!
//! A boot counter persists across runs, so flashing once and power-cycling
//! twice proves both modules keep data through real power loss. The
//! contract semantics themselves are verified host-side (tests/*.test.ts,
//! the crates' unit tests); this binary only asks the questions hardware
//! can answer: does it compile here, does LittleFS behave, what does it
//! cost.

use std::time::Instant;

use esp_idf_svc::fs::littlefs::Littlefs;
use esp_idf_svc::io::vfs::MountedLittlefs;
use pocket_db::{DbModule, Storage as DbStorage};
use pocket_fs::{FsModule, Storage as FsStorage};
use serde_json::Value as Json;

const WORKSPACE_ROOT: &str = "/workspace";
const DATA_ROOT: &str = "/workspace/apps/smoke/data";
const TMP_DIR: &str = "/workspace/apps/smoke/tmp";

fn main() {
    esp_idf_svc::sys::link_patches();
    esp_idf_svc::log::EspLogger::initialize_default();
    match run() {
        Ok(boot) => log::info!("DATA-SMOKE: PASS boot={boot}"),
        Err(error) => log::error!("DATA-SMOKE: FAIL: {error:#}"),
    }
    loop {
        std::thread::sleep(std::time::Duration::from_secs(10));
        log::info!("DATA-SMOKE: idle");
    }
}

fn run() -> anyhow::Result<i64> {
    let _mount = mount_workspace()?;
    std::fs::create_dir_all(DATA_ROOT)?;
    let heap_before = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() };

    let boot = fs_smoke()?;
    db_smoke()?;

    let heap_after = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() };
    log::info!(
        "DATA-SMOKE: heap before {heap_before} after {heap_after} (delta {})",
        heap_before as i64 - heap_after as i64
    );
    Ok(boot)
}

fn expect(condition: bool, what: &str) -> anyhow::Result<()> {
    anyhow::ensure!(condition, "expectation failed: {what}");
    Ok(())
}

// --- fs: the nine-op contract against real LittleFS ------------------------

fn fs_smoke() -> anyhow::Result<i64> {
    let started = Instant::now();
    let mut fs = FsModule::new(FsStorage::Dir {
        root: DATA_ROOT.into(),
        tmp: TMP_DIR.into(),
    });

    // Boot counter: truncate-write on every boot; its value is the proof
    // that atomic writes and LittleFS persistence survive power cycling.
    let boot = match parse(&fs.read("boot.txt", 0, 64)) {
        Ok(line) => {
            let b64 = line["data"]["$b"].as_str().unwrap_or_default();
            String::from_utf8(base64_decode(b64))?.trim().parse::<i64>()? + 1
        }
        Err(_) => 0, // first boot on a fresh partition
    };
    let write = fs.write("boot.txt", &format!("{:?}", boot.to_string()), 0);
    expect(write == 0, "boot counter write")?;

    // Text + append round-trip.
    expect(fs.write("notes/hello.md", "\"# hi\"", 0) == 0, "write text")?;
    expect(fs.write("notes/hello.md", "\" there\"", 1) == 0, "append text")?;
    let read = parse(&fs.read("notes/hello.md", 0, 64))?;
    expect(read["size"].as_i64() == Some(10), "size after append")?;
    expect(read["eof"].as_bool() == Some(true), "eof")?;

    // Bytes round-trip via the {"$b": base64} spelling.
    expect(
        fs.write("raw.bin", r#"{"$b":"AAEC/w=="}"#, 0) == 0,
        "write bytes",
    )?;
    let stat = parse(&fs.stat("raw.bin"))?;
    expect(stat["size"].as_i64() == Some(4), "bytes size")?;

    // list is name-sorted; mkdir/rename/remove behave. (Listing a fresh
    // subdirectory, not the root — the root also holds the db module's
    // ordinary files, main.sqlite and a transient journal.)
    expect(fs.mkdir("assets/img") == 0, "mkdir -p")?;
    expect(fs.rename("raw.bin", "assets/raw.bin") == 0, "rename")?;
    let listing = parse(&fs.list("assets", 0))?;
    let names: Vec<&str> = listing["entries"]
        .as_array()
        .map(|entries| entries.iter().filter_map(|e| e["name"].as_str()).collect())
        .unwrap_or_default();
    anyhow::ensure!(names == ["img", "raw.bin"], "listing sorted: got {names:?}");
    expect(fs.remove("assets", 0) == 1, "non-recursive remove of full dir refused")?;
    expect(fs.remove("assets", 1) == 0, "recursive remove")?;

    // The sandbox refusal holds on-device exactly as in the goldens, and
    // universal names (dot-prefixed, CJK) round-trip on real LittleFS.
    expect(
        parse(&fs.read("../../etc/passwd", 0, 16)).is_err(),
        "traversal refused",
    )?;
    expect(fs.write(".config", "\"k=v\"", 0) == 0, "dot name allowed")?;
    expect(fs.write("笔记/今天.md", "\"你好\"", 0) == 0, "CJK name allowed")?;
    expect(fs.remove("笔记", 1) == 0 && fs.remove(".config", 0) == 0, "cleanup")?;

    let usage = parse(&fs.usage())?;
    log::info!(
        "DATA-SMOKE: fs ok in {:?}; boot {boot}; usedBytes {}",
        started.elapsed(),
        usage["usedBytes"]
    );
    Ok(boot)
}

// --- db: SQLite through the module core over the same data root ------------

fn db_smoke() -> anyhow::Result<()> {
    let started = Instant::now();
    let mut db = DbModule::new(DbStorage::Dir(DATA_ROOT.into()));
    let handle = db.open("main");
    anyhow::ensure!(handle > 0, "db open failed");

    expect(
        db.exec(
            handle,
            "CREATE TABLE IF NOT EXISTS samples (
                 captured_at   INTEGER PRIMARY KEY,
                 total_cents   INTEGER NOT NULL
             );",
        ) == 0,
        "ddl",
    )?;

    // Prior completed runs' rows must still be there, and ONLY whole
    // transactions: a run interrupted mid-transaction (reset, power loss)
    // contributes exactly zero rows. The %288 invariant is SQLite's
    // atomicity witnessed across power cycles, through the module.
    let prior = parse(&db.query(handle, "SELECT COUNT(*) FROM samples", "[]"))?;
    let prior_rows = prior["rows"][0][0].as_i64().unwrap_or(-1);
    expect(prior_rows >= 0 && prior_rows % 288 == 0, "whole transactions only")?;

    // One day of 5-minute samples in one transaction — the flash-wear shape.
    let tx_started = Instant::now();
    expect(db.exec(handle, "BEGIN") == 0, "begin")?;
    for i in 0..288i64 {
        let at = (prior_rows + i) * 300;
        let cents = 1_500_000 + (i % 97) * 137;
        let line = db.query(
            handle,
            "INSERT INTO samples (captured_at, total_cents) VALUES (?, ?)",
            &format!("[{at}, {cents}]"),
        );
        parse(&line)?;
    }
    expect(db.exec(handle, "COMMIT") == 0, "commit")?;
    let tx_elapsed = tx_started.elapsed();

    let agg = parse(&db.query(handle, "SELECT COUNT(*) FROM samples", "[]"))?;
    expect(
        agg["rows"][0][0].as_i64() == Some(prior_rows + 288),
        "aggregate row count",
    )?;

    // The ATTACH refusal holds on-device; the database is an ordinary
    // file in the app's data root.
    expect(
        db.exec(handle, "ATTACH DATABASE '/workspace/x' AS other") == 1,
        "attach refused",
    )?;
    expect(
        std::path::Path::new(DATA_ROOT).join("main.sqlite").is_file(),
        "db is an ordinary file in the data root",
    )?;

    log::info!(
        "DATA-SMOKE: db ok in {:?} (288-row tx {tx_elapsed:?})",
        started.elapsed()
    );
    Ok(())
}

// --- small helpers ----------------------------------------------------------

/// Parse one op result line; an {"error": ...} shape becomes an Err.
fn parse(line: &str) -> anyhow::Result<Json> {
    let value: Json = serde_json::from_str(line)?;
    match value.get("error").and_then(Json::as_str) {
        Some(error) => anyhow::bail!("op error: {error}"),
        None => Ok(value),
    }
}

/// Minimal base64 decode (standard alphabet, padded) — enough for the boot
/// counter without pulling a crate into the example.
fn base64_decode(s: &str) -> Vec<u8> {
    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let value = |c: u8| ALPHABET.iter().position(|&a| a == c).unwrap_or(0) as u32;
    let s = s.trim_end_matches('=').as_bytes();
    let mut out = Vec::with_capacity(s.len() * 3 / 4);
    for chunk in s.chunks(4) {
        let mut n = 0u32;
        for (i, &c) in chunk.iter().enumerate() {
            n |= value(c) << (18 - 6 * i);
        }
        for i in 0..chunk.len().saturating_sub(1) {
            out.push((n >> (16 - 8 * i)) as u8);
        }
    }
    out
}

// --- LittleFS mount (the pocket-pi firmware's semantics: format only a
// blank partition, never a corrupted one) -----------------------------------

type WorkspaceMount = MountedLittlefs<Littlefs<()>>;

fn mount_workspace() -> anyhow::Result<WorkspaceMount> {
    let fs = unsafe { Littlefs::<()>::new_partition("workspace")? };
    match MountedLittlefs::mount(fs, WORKSPACE_ROOT) {
        Ok(mounted) => Ok(mounted),
        Err(_mount_error) if partition_is_blank()? => {
            let mut fs = unsafe { Littlefs::<()>::new_partition("workspace")? };
            fs.format()?;
            MountedLittlefs::mount(fs, WORKSPACE_ROOT).map_err(Into::into)
        }
        Err(mount_error) => Err(anyhow::anyhow!(
            "LittleFS workspace mount failed; preserving non-blank partition: {mount_error}"
        )),
    }
}

fn partition_is_blank() -> anyhow::Result<bool> {
    let partition = unsafe {
        esp_idf_svc::sys::esp_partition_find_first(
            esp_idf_svc::sys::esp_partition_type_t_ESP_PARTITION_TYPE_DATA,
            esp_idf_svc::sys::esp_partition_subtype_t_ESP_PARTITION_SUBTYPE_DATA_LITTLEFS,
            c"workspace".as_ptr(),
        )
    };
    if partition.is_null() {
        anyhow::bail!("LittleFS workspace partition is missing");
    }
    let mut prefix = [0u8; 4096];
    let status = unsafe {
        esp_idf_svc::sys::esp_partition_read(partition, 0, prefix.as_mut_ptr().cast(), prefix.len())
    };
    if status != esp_idf_svc::sys::ESP_OK {
        anyhow::bail!("read LittleFS workspace partition: ESP error {status}");
    }
    Ok(prefix.iter().all(|byte| *byte == 0xff))
}
05

Bring-up guide

upstream documents · rendered verbatim

Toolchain, build, deploy and acceptance are owned by pocket-stack/pocketjs. The documents below are rendered from the pinned checkout without edits; relative links point back into the repository at the same revision.

PocketJS on ESP32-P4

2 min read · 507 words

Add the ESP-IDF component, enable the Rust adapter, the buffer contract, the smoke build.

rendered verbatim fromhosts/esp32p4/README.md@ 6c43f49raw ↗

This directory contains the reusable ESP-IDF half of the PocketJS ESP32-P4 RGB565 renderer. Together with engine/backends/esp32p4-ppa, it is a concrete PPA backend:

  • the no_std Rust crate interprets DrawLists, batches A8 coverage, selects hardware-compatible operations, and preserves order with its RGB565 software fallback;
  • components/pocketjs_ppa registers one ESP-IDF client each for FILL, BLEND, and SRM and executes blocking transactions;
  • the product BSP owns display initialization, native presentation buffers, rotation into panel scan order, and vblank/present scheduling.

No full-frame RGB888 or ARGB8888 intermediate is required.

Compatibility

The adapter is supported and build-tested with the ESP-IDF release/v6.0 and release/v6.1 branches. Versions older than v6.0 have not been tested. CI builds release/v6.0 as the minimum supported baseline.

Only the esp32p4 target is supported. The adapter does not select a silicon revision, CPU frequency, PSRAM mode, display controller, or panel timing; those remain product/BSP configuration.

Add the ESP-IDF component

Add this repository's component directory to the host project before loading ESP-IDF's project support:

list(APPEND EXTRA_COMPONENT_DIRS
    "/path/to/pocketjs/hosts/esp32p4/components"
)

include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(my_pocketjs_host)

The host component that links the Rust archive should declare REQUIRES pocketjs_ppa. The component roots its C ABI symbols for the final link, so archive ordering does not require additional linker flags.

Enable the Rust adapter

Depend on the renderer with its esp-idf feature:

[dependencies]
pocketjs-core = { path = "/path/to/pocketjs/engine/core" }
pocketjs-esp32p4-ppa = { path = "/path/to/pocketjs/engine/backends/esp32p4-ppa", features = ["esp-idf"] }

Create one EspIdfPpaOps on the rendering task and pass it to the persistent renderer:

use pocketjs_esp32p4_ppa::{
    EspIdfPpaOps, Renderer, RendererConfig,
};

let mut ppa = EspIdfPpaOps::new().expect("PPA clients");
let mut renderer = Renderer::new(RendererConfig::default())
    .expect("valid renderer configuration");

let stats = renderer.render(
    &ui,
    draw_list_words,
    rgb565_framebuffer,
    framebuffer_width,
    framebuffer_height,
    &mut ppa,
);

Dropping EspIdfPpaOps unregisters its clients. Drop it before shutting down the platform PPA/display resources.

Buffer contract

The caller owns all image memory and must obey the ESP-IDF PPA DMA/cache contract:

  • allocate RGB565 output buffers with DMA-capable memory;
  • align every output address and byte size to the platform cache-line size; 128-byte alignment is a safe ESP32-P4 host policy;
  • keep SRM input and output ranges distinct;
  • do not modify or reuse a buffer while any nonblocking presentation transaction is reading or writing it.

The DrawList transactions in this adapter are blocking. This is intentional: when an operation returns, subsequent PPA operations and ordered CPU fallback segments must see its completed pixels. Display presentation can use a separate nonblocking PPA client in the board BSP.

FILL colors are passed through fill_argb_color after expanding RGB565 to 8-bit channels. Passing a packed RGB565 word through fill_color_val produces incorrect colors. SRM is accepted only when both scale factors are represented exactly in the PPA's 1/16 increments; otherwise the Rust renderer uses the software fallback.

Build smoke test

With an activated ESP-IDF environment:

cd hosts/esp32p4/examples/ppa-smoke
idf.py set-target esp32p4
idf.py build

This verifies the component API and final link. It does not exercise pixels on hardware; visual and performance verification still belongs to a product host with a display BSP.

Photo

Wikimedia Commons · Espressif ESP32-P4.jpg — Pathfinbird, CC BY-SA 4.0. The SoC on a development board; no M5Stack Tab5 photo is available under a free licence.