Native apps give you the full performance of the ESP32-C6 with direct access to the App SDK surface. You write C, build it alongside the firmware using the standard ESP-IDF toolchain inside Docker, and deploy the resulting .bin to the SD card.
Native apps share the same manifest format, capability/permission model, and SDK functions as MicroPython apps — the difference is only in the entry point and build system.
Prerequisites¶
- Docker Desktop (macOS/Windows) or Docker Engine (Linux), running
- The JPPDOS firmware repository cloned locally
You do not need to install ESP-IDF, a RISC-V toolchain, or Python on your host — everything runs inside the Docker container.
How native apps work¶
A native app binary is an ELF32 RISC-V shared object (ET_DYN) compiled with position-independent code flags. The firmware's native loader (jpp_native_loader_core) maps it into a 64 KB static pool in BSS at launch, resolves all undefined symbols from the firmware's exported symbol table, and calls your entry point.
All firmware functions (jpp_sdk_*, esp_log_write, standard C library functions) are resolved at load time from the symbol table — you never link against a separate SDK library. This means:
- Your app binary is small — it contains only your own code
- The app and firmware must be built from the same source tree so symbol names match
- You cannot call any function not in the firmware's symbol table
UNRESOLVED_SYM at launch means exactly one thing.
Your app called a function the running firmware does not export. There is no
link-time check to catch this — the loader is the first thing that notices.
Either the firmware is older than the SDK level your app needs (declare the
right sdk_min so the loader rejects it with a
clear SDK_TOO_OLD instead), or the symbol was never added to the
firmware's export table at all.
App layout¶
/sd/apps/my_app/
├── manifest.json ← app metadata; app_type must be "native"
└── my_app.bin ← ELF32 RISC-V shared object
Additional data files (configs, assets, game modules) can live alongside the .bin and are accessible via the scoped file API.
Entry point¶
Every native app must export exactly one function:
#include "jpp_sdk_bridge.h"
void jpp_app_entry(jpp_sdk_context_t *ctx);
The loader resolves this by name from the ELF symbol table and calls it directly. jpp_app_entry owns its own loop — it runs synchronously and the app exits when the function returns. There are no lifecycle hooks like the MicroPython model; you poll keys yourself:
void jpp_app_entry(jpp_sdk_context_t *ctx)
{
const char *frame[] = { "Counter", "", "0" };
jpp_sdk_set_frame(ctx, frame, 3);
int count = 0;
jpp_sdk_key_event_t key;
while (true) {
jpp_sdk_wait_key(ctx, 0, &key);
if (key == JPP_SDK_KEY_BACK) {
break; /* exit the app */
} else if (key == JPP_SDK_KEY_UP) {
count++;
} else if (key == JPP_SDK_KEY_DOWN) {
count--;
}
char val[16];
snprintf(val, sizeof(val), "%d", count);
const char *lines[] = { "Counter", "", val };
jpp_sdk_set_frame(ctx, lines, 3);
}
jpp_sdk_request_close(ctx);
}
Background task entry point¶
If your app uses background tasks, export this additional function (optional — if absent, background scheduling is not available):
void jpp_app_task_entry(jpp_sdk_context_t *ctx, const char *task_name);
The firmware calls this instead of jpp_app_entry during headless background runs.
Background runs are headless and time-boxed.
No UI, no key events, and no consent prompts — every permission request is denied outright, so only tier-1 capabilities the user granted in a previous foreground launch are usable. Return within the 30-second quota; a task that overruns is killed by restarting the device.
Build system integration¶
Quick start: the jppd-app-sdk toolchain (recommended)¶
If you just want to build your app — not the whole firmware — use the
self-contained Docker toolchain from the
jppdos-apps repository. It bakes a
firmware build as an SDK sysroot, so you need no firmware checkout, no idf.py,
and no CMakeLists.txt — just a prebuilt jppd-app-sdk image:
# from your app source directory (just manifest.json + src/**/*.c):
docker run --rm -v "$PWD:/app" jppd-app-sdk # → ./dist/<app_id>/
docker run --rm -v "$PWD:/app" --device /dev/ttyACM0 \
jppd-app-sdk --upload /dev/ttyACM0 # build + upload
The tool auto-detects native vs MicroPython from your manifest, compiles all
src/**/*.c, validates the manifest, and stages <entry>.bin + manifest.json.
Shared helpers, extra includes, and defines go in an optional jppd-app.json
(see that repo's toolchain/README.md). The image is version-locked to a
firmware release — rebuild it when you move to a new firmware version.
To deploy several apps at once, with serial-port autodiscovery, use the same
repo's ./deploy.py instead of --upload.
Adding your app to the firmware build¶
The alternative is to build your app inside the firmware tree (this is how the in-repo example apps build, and what you want when developing the firmware itself).
Create apps/my_app/CMakeLists.txt following the pattern from an existing app (e.g. apps/testapp_native/CMakeLists.txt, the smallest single-binary example). The custom target calls build_shared.py, which:
- Reads
build/compile_commands.jsonfor the correct include paths - Recompiles your sources as a RISC-V shared library (ELF32 ET_DYN)
- Places the
.binandmanifest.jsoninbuild/apps/my_app/
After adding a new apps/ directory, run idf.py reconfigure once before building — ESP-IDF caches the component list:
docker compose run --rm build idf.py reconfigure
docker compose run --rm build idf.py build
The firmware and all app binaries are built together in one pass. After a clean build, your deployable package is ready at build/apps/my_app/.
Build flags¶
Your sources are compiled with these flags (handled by build_shared.py):
-fPIC -shared -mno-relax -nostartfiles
-march=rv32imc_zicsr_zifencei -mabi=ilp32
Do not hard-code these — they come from compile_commands.json. Do not use -O0; the SDK headers include inline functions that require optimization to eliminate dead branches.
Key types and headers¶
The only SDK header you need for most apps:
#include "jpp_sdk_bridge.h"
This pulls in the full SDK surface: all jpp_sdk_* functions, jpp_sdk_context_t, jpp_sdk_key_event_t, jpp_sdk_status_t, jpp_buzzer_note_t, jpp_sdk_ble_scan_result_t, and all constants.
Key types¶
jpp_sdk_context_t *ctx¶
An opaque pointer to the SDK context bound to your app instance. Pass it as the first argument to every jpp_sdk_* call. Do not store it in a global — the context is only valid for the lifetime of jpp_app_entry.
jpp_sdk_status_t¶
Return type for most SDK calls:
| Constant | Meaning |
|---|---|
JPP_SDK_OK |
Success |
JPP_SDK_INVALID_ARGUMENT |
Bad parameter |
JPP_SDK_INVALID_STATE |
Called at wrong time (e.g. no active session) |
JPP_SDK_ACCESS_DENIED |
Capability not granted by the user |
JPP_SDK_TEXT_TRUNCATED |
Output fit but was cut short |
JPP_SDK_BROKER_ERROR |
Internal broker failure |
JPP_SDK_HANDLE_LIMIT |
Too many open file handles |
JPP_SDK_INVALID_HANDLE |
Handle is not open or was closed |
JPP_SDK_BLE_CONN_LIMIT |
Too many open BLE connections |
JPP_SDK_INVALID_BLE_CONN |
Connection handle is not valid |
JPP_SDK_NO_DATA |
Requested data does not exist (e.g. missing KV key) |
jpp_sdk_key_event_t¶
| Constant | Meaning |
|---|---|
JPP_SDK_KEY_NONE |
No key / timeout |
JPP_SDK_KEY_UP |
Up |
JPP_SDK_KEY_DOWN |
Down |
JPP_SDK_KEY_LEFT |
Left |
JPP_SDK_KEY_RIGHT |
Right |
JPP_SDK_KEY_CENTER |
Center press |
JPP_SDK_KEY_BACK |
The user asked to go back |
JPP_SDK_KEY_CENTER_LONG |
Older name for JPP_SDK_KEY_BACK, same value |
JPP_SDK_KEY_CENTER_HOLD |
Raw CENTER hold — only if claimed |
JPP_SDK_KEY_CENTER_DOUBLE |
Raw CENTER double-click — only if claimed |
Which gesture means “back” is not your app’s business.
The user chooses hold or double-click in Settings → Controls, and the
firmware translates it to JPP_SDK_KEY_BACK before you see it. Take a
gesture over as your own input only with
jpp_sdk_claim_center — and then your
app owns its own way out. JPP_SDK_KEY_BACK and the claim API need
sdk_min: 2.
Deploying¶
After the build, copy the entire contents of build/apps/my_app/ to the SD card:
cp -r build/apps/my_app/ /Volumes/SD/apps/my_app/
The directory must contain manifest.json and the .bin. Any additional data files your app expects should also be included.
Complete example: BLE device scanner¶
This app scans for nearby BLE devices and displays the results. The user can scroll through the list and view RSSI.
"sdk_min": 2 here is needed for JPP_SDK_KEY_BACK alone — ble.scan itself
has been available since level 1.
manifest.json:
{
"schema_version": 2,
"app_id": "blescanner",
"name": "BLE Scanner",
"version": "1.0.0",
"sdk_min": 2,
"app_type": "native",
"entry": "blescanner.bin",
"capabilities": ["ble.scan"],
"background": { "enabled": false }
}
blescanner.c:
#include "jpp_sdk_bridge.h"
#include <stdio.h>
#include <string.h>
#define MAX_RESULTS 20
static void show_scanning(jpp_sdk_context_t *ctx)
{
const char *lines[] = { "BLE Scanner", "", "Scanning...", "", "Please wait" };
jpp_sdk_set_frame(ctx, lines, 5);
}
static void show_results(jpp_sdk_context_t *ctx,
jpp_sdk_ble_scan_result_t *results,
size_t count, int cursor)
{
char row1[32], row2[32], row3[32], row4[32];
if (count == 0) {
const char *lines[] = { "BLE Scanner", "", "No devices found",
"", "OK=scan again" };
jpp_sdk_set_frame(ctx, lines, 5);
return;
}
snprintf(row1, sizeof(row1), "%zu found [%d/%zu]",
count, cursor + 1, count);
const char *name = results[cursor].name[0]
? results[cursor].name : "(unnamed)";
snprintf(row2, sizeof(row2), "%.20s", name);
snprintf(row3, sizeof(row3), "%s", results[cursor].address);
snprintf(row4, sizeof(row4), "RSSI: %d dBm", results[cursor].rssi);
const char *lines[] = { "BLE Scanner", row1, row2, row3, row4,
"", "OK=scan again" };
jpp_sdk_set_frame(ctx, lines, 7);
}
void jpp_app_entry(jpp_sdk_context_t *ctx)
{
jpp_sdk_ble_scan_result_t results[MAX_RESULTS];
jpp_broker_result_t broker;
size_t count = 0;
int cursor = 0;
bool scanned = false;
const char *intro[] = { "BLE Scanner", "", "Press OK to scan" };
jpp_sdk_set_frame(ctx, intro, 3);
jpp_sdk_key_event_t key;
while (true) {
jpp_sdk_wait_key(ctx, 0, &key);
if (key == JPP_SDK_KEY_BACK) {
break;
} else if (key == JPP_SDK_KEY_CENTER) {
show_scanning(ctx);
jpp_sdk_status_t st = jpp_sdk_ble_scan(ctx, 3000,
results, MAX_RESULTS,
&count, &broker);
if (st == JPP_SDK_ACCESS_DENIED) {
const char *denied[] = { "BLE Scanner", "",
"BLE scan denied.", "", "Back=exit" };
jpp_sdk_set_frame(ctx, denied, 5);
scanned = false;
continue;
}
cursor = 0;
scanned = true;
show_results(ctx, results, count, cursor);
} else if (scanned && count > 0) {
if (key == JPP_SDK_KEY_DOWN && cursor < (int)count - 1) {
cursor++;
show_results(ctx, results, count, cursor);
} else if (key == JPP_SDK_KEY_UP && cursor > 0) {
cursor--;
show_results(ctx, results, count, cursor);
}
}
}
jpp_sdk_request_close(ctx);
}
Tips¶
- Always call
jpp_sdk_request_close(ctx)before returning fromjpp_app_entry. The firmware calls close on your behalf if you forget, but explicit cleanup is cleaner. - Use
jpp_sdk_wait_key(ctx, 0, &key)(timeout 0 = wait forever) as the main loop driver when you have no time-sensitive work to do between key events. - Use
jpp_sdk_wait_key(ctx, 100, &key)with a short timeout when you need periodic updates (e.g. refreshing a clock display). - Check the return value of every SDK call.
JPP_SDK_ACCESS_DENIEDis expected and normal — handle it gracefully rather than treating it as a fatal error. - Honour
JPP_SDK_KEY_BACKwherever it makes sense to exit or go up a level. Don't try to detect the physical gesture behind it unless you have deliberately claimed one. - The 64 KB app pool holds your entire binary. A typical app is 5–15 KB; complex apps with many data structures may approach 50 KB. If you need more, structure your code as a hub + loaded modules (see Code modules).
snprintfis available from the standard C library via the symbol table.malloc/freework too, but prefer stack allocation — the device heap is shared with Wi-Fi and other firmware services.