ESP-IDF
PocketJS supports ESP32-P4 and ESP32-S3 as ESP-IDF components. The six base components do not own the firmware task, input drivers, display controller, physical buffers, or presentation. A product selects the components it needs and calls them from its native code. The optional runner is the only component that creates an owner task.
Requirements
| Input | Requirement |
|---|---|
| ESP-IDF | >=6.0,<6.2 |
| JavaScript engine | espressif/quickjs-ng 0.14.0, pulled by pocketjs_guest |
Prebuilt .pocket |
No Bun requirement during idf.py build |
| Source application | Installed pocket CLI and Bun |
| Native core | Prebuilt in Registry releases; Rust is optional for consumers |
P4 and S3 share the same package, guest, UI, and renderer interfaces. P4 may add PPA acceleration. S3 executes the complete RGB565 software path.
Components
pocketjs_package
pocketjs_guest -> quickjs-ng
pocketjs_ui_core
pocketjs_ui_qjs -> pocketjs_guest + pocketjs_ui_core
pocketjs_render_rgb565 -> pocketjs_ui_core
pocketjs_esp32p4_ppa -> pocketjs_render_rgb565 + esp_driver_ppa
pocketjs_runner -> pocketjs_ui_qjsFor an S3 UI, declare pocketjs_package, pocketjs_ui_qjs, and
pocketjs_render_rgb565. For P4, replace the renderer leaf with
pocketjs_esp32p4_ppa; it pulls in the renderer. Add pocketjs_runner only
when the library should create the owner task.
Host profile
Each firmware repository owns a pocket.host.json. It records the display and
input contract implemented by that product:
{
"$schema": "https://pocketjs.dev/schema/pocket-idf-host-1.json",
"version": 1,
"id": "idf-smoke",
"platform": "esp-idf",
"form": "takeover",
"tickHz": 60,
"display": {
"physicalViewport": [320, 240],
"logicalViewports": [[320, 240]],
"presentations": ["native"],
"rasterDensity": 1
},
"capabilities": ["input.buttons", "text.glyphs.baked"]
}id occupies at most 15 UTF-8 bytes because it is stored in the .pocket
variant table. Capabilities describe framework APIs that the firmware actually
delivers. A GPIO, touch controller, or analog device does not belong in this
file unless the native host converts it into the corresponding PocketJS input
contract. A profile that advertises input.touch is limited to 512×512
logical viewports because each frame carries 9-bit logical coordinates.
The build hashes the canonical profile. Device admission rejects a package whose target, HostOps ABI, tick rate, viewport, density, presentation, or profile hash differs from the compiled host contract.
Build an application package
Prebuilt package
Build outside ESP-IDF:
pocket build \
--manifest app/pocket.json \
--host-profile firmware/pocket.host.json \
--project-root app \
--outdir dist \
--output dist/dashboard.pocketEmbed the result from the consumer component after
idf_component_register:
pocketjs_embed_package(
TARGET ${COMPONENT_LIB}
NAME dashboard
PACKAGE "${CMAKE_SOURCE_DIR}/app/dist/dashboard.pocket"
HOST_PROFILE "${CMAKE_SOURCE_DIR}/firmware/pocket.host.json"
)The generated pocketjs_package_dashboard.h exports the borrowed package
bytes and the host contract extracted from its binary host-input section.
This path does not invoke Bun or Rust during idf.py build.
Compile from the IDF build
The optional helper invokes an installed PocketJS CLI:
pocketjs_compile_app(
TARGET ${COMPONENT_LIB}
NAME dashboard
MANIFEST "${CMAKE_SOURCE_DIR}/app/pocket.json"
HOST_PROFILE "${CMAKE_SOURCE_DIR}/firmware/pocket.host.json"
PROJECT_ROOT "${CMAKE_SOURCE_DIR}/app"
)The helper never installs Bun, Node packages, or Rust. A missing pocket
executable is a configuration error. Generated JS, PAK, package, C, and
assembly files stay under the IDF build directory.
The compiler emits a depfile from the bundler graph and resource reads.
Ninja tracks imported modules, images, fonts, configuration, the host profile,
and compiler inputs. Adding an imported module does not require reconfigure.
COMPILER_RECEIPT adds a product-owned toolchain receipt as a dependency;
DEPENDS lists additional inputs read by custom build steps.
Package
Open and select the embedded package before creating the guest:
pocketjs_package_t *package = NULL;
ESP_ERROR_CHECK(pocketjs_package_open(
pocketjs_package_dashboard.data,
pocketjs_package_dashboard.size,
0,
&package));
pocketjs_package_variant_t app = {
.struct_size = sizeof(app),
};
ESP_ERROR_CHECK(pocketjs_package_select(
package,
&pocketjs_package_dashboard_contract,
&app));Package, JS, PAK, and plan spans point into the caller-owned source bytes. Keep those bytes readable until the guest, binding, and package handle have been destroyed. Storage may be embedded flash, an mmap-capable partition, or another product-owned source.
The PAK ArrayBuffer exposed to JavaScript is immutable and still borrows
the source bytes. No complete PAK copy is allocated in the JS heap.
pakGet() returns a writable copy of the requested entry. Native code must
keep the borrowed package readable and unchanged for its documented lifetime.
Guest
pocketjs_guest creates one QuickJS runtime and context. It evaluates an IIFE,
calls globalThis.frame once per turn, and drains pending Promise jobs. It has
no UI until a surface is mounted.
pocketjs_guest_config_t config;
pocketjs_guest_config_defaults(&config);
config.heap_limit = 4 * 1024 * 1024;
config.prefer_psram = true;
pocketjs_guest_t *guest = NULL;
ESP_ERROR_CHECK(pocketjs_guest_create(&config, &guest));pocketjs/guest_quickjs.h exposes a version-pinned JSContext * only for
native surface and extension code. Application hosts that do not install raw
QuickJS functions include pocketjs/guest.h instead.
UI core
The UI core is independent from QuickJS and the RGB565 renderer:
pocketjs_ui_core_config_t config;
pocketjs_ui_core_config_defaults(&config);
config.logical_width = contract.logical_width;
config.logical_height = contract.logical_height;
config.raster_density = contract.raster_density;
config.tick_hz = contract.tick_hz;
pocketjs_ui_core_t *core = NULL;
ESP_ERROR_CHECK(pocketjs_ui_core_create(&config, &core));Native firmware can call the tree mutation API directly without creating a
guest. A pocketjs_ui_frame_view_t borrows the latest DrawList and resource
state. Its pointers expire on the next draw, mutation, or tick. Its epoch
is a borrow generation, not a snapshot or a logical-state revision.
UI QuickJS binding
Create the binding with caller-owned guest and core handles, feed its PAK, then mount before bundle evaluation. The binding derives viewport and tick rate from the core; its own config carries only target id and HostOps ABI.
PAK loading validates and stages resources before committing them to the core. A returned error leaves the core and binding unchanged and permits a retry. Successfully installed resources belong to the core.
One guest accepts one UI binding. UI functions capture that binding in native closures; they do not use the QuickJS context opaque slot. Other native extensions can coexist in the same guest.
ESP_ERROR_CHECK(pocketjs_ui_qjs_feed_pak(binding, app.pak.data, app.pak.size));
ESP_ERROR_CHECK(pocketjs_ui_qjs_mount(binding));
ESP_ERROR_CHECK(pocketjs_guest_eval(
guest,
(const char *)app.javascript.data,
app.javascript.size - 1,
"dashboard"));One caller-driven frame is:
pocketjs_ui_input_t input = {
.struct_size = sizeof(input),
.buttons = buttons,
};
pocketjs_ui_frame_view_t frame = {
.struct_size = sizeof(frame),
};
ESP_ERROR_CHECK(pocketjs_ui_turn(binding, &input, &frame));The binding converts signed analog axes to the framework range and packs up to eight logical touch contacts. Touch coordinates must fit the current 9-bit per-axis frame contract. Hardware SDK types stop in the product input adapter.
RGB565 renderer
Create one renderer and one target state per persistent physical buffer. The
renderer config's scale must equal the UI core's raster_density. The
presentation transaction is:
pocketjs_rgb565_preparereturns zero to eight logical damage rectangles.pocketjs_rgb565_render_strippaints each rectangle into a caller-owned full-viewport-width strip. Its physical dimensions arelogical_width * scalebyregion.height * scale; pixels outside the rectangle's horizontal interval remain unchanged.- The product transfers only the rectangle window, or preserves the untouched columns before transferring the complete strip. Rotation, scaling, and composition stay in this product-owned step.
- After every transfer succeeds, call
pocketjs_rgb565_commit. - After a partial update or transfer failure, call
pocketjs_rgb565_abort.
Commit advances only the selected target's history. Double-buffered firmware
therefore keeps two independent target handles. The renderer does not wait on
DMA or retain a pointer to the strip after render_strip returns.
ESP32-P4 PPA
P4 firmware can construct an accelerator and pass its borrowed vtable to
render_strip:
pocketjs_esp32p4_ppa_t *ppa = NULL;
ESP_ERROR_CHECK(pocketjs_esp32p4_ppa_create(&ppa));
const pocketjs_rgb565_accelerator_t *accelerator =
pocketjs_esp32p4_ppa_accelerator(ppa);PPA operations are blocking because CPU fallback commands later in the same DrawList must observe completed pixels. Destination buffers must satisfy the IDF PPA DMA/cache contract; 128-byte address and size alignment is the supported P4 policy. Display presentation uses a separate BSP-owned client when it needs asynchronous scaling or transfer.
Optional runner
pocketjs_runner owns one task and calls the same synchronous
pocketjs_ui_turn function. Its callbacks supply input and consume the frame
view:
pocketjs_runner_config_t config;
pocketjs_runner_config_defaults(&config);
config.sample_input = sample_input;
config.after_turn = render_and_present;
ESP_ERROR_CHECK(pocketjs_runner_start(binding, &config, &runner));The runner reads tickHz from the mounted binding's UI core. Deadlines are
derived from t0 + frame / tickHz, so 60 Hz is not represented as a drifting
16 ms period. The runner catches up late turns and drops stale turns only after
exceeding max_lag_us. pocketjs_runner_stop interrupts a running JS turn and
joins the task before returning. It does not destroy any borrowed handle.
Shutdown
For caller-driven firmware:
- stop calling
pocketjs_ui_turn; - finish or abort the active renderer transaction;
- destroy PPA, target states, and renderer;
- destroy the guest;
- destroy the UI binding and core;
- close the package after its borrowed bytes are no longer referenced.
When using the runner, call pocketjs_runner_stop before step 2.
Rust archives
Registry releases contain separate core and renderer archives for P4 and S3:
pocketjs_ui_core owns libpocketjs_idf_ui_core.a, and
pocketjs_render_rgb565 owns libpocketjs_idf_render_rgb565.a. The renderer
uses C resource views, not the private Rust layout of the core. Set
POCKETJS_RUST_FROM_SOURCE=ON only when modifying the core or renderer. The
build checks the developer-provided Cargo, compiler target, and linker and does
not install a Rust toolchain.
The source targets are:
- P4:
riscv32imafc-unknown-none-elf - S3:
xtensa-esp32s3-none-elf
When POCKETJS_CARGO selects a custom executable, the component uses the
rustc beside it. This keeps the Cargo/compiler pair from the same toolchain.
For P4, archive preparation removes Rust-bundled soft-float compiler-rt C
members. ESP-IDF supplies the equivalent runtime symbols from ROM or libgcc
under its ilp32f ABI.
Error and allocation policy
Invalid configuration is rejected with an error code, including raster
density outside 1..=255. C and QuickJS allocation failures are reported
where their APIs permit recovery. Rust allocation exhaustion is fatal.
The native core and renderer do not promise to translate every OOM into
ESP_ERR_NO_MEM; provision their memory separately from the guest heap limit.
Examples
hosts/esp-idf/examples/smoke compiles and links the caller-driven package,
guest, UI, renderer, and P4 accelerator path. examples/runner executes the
same package on the optional owner task. Both examples use a headless RGB565
buffer; product panel initialization remains in the BSP.