Firmware v1.1 · 2026-07-29
This document defines the native/app boundary for JPPDOS. The native ESP-IDF core in components/jpp_core/ owns boot sequencing, storage, settings, broker policy, hardware drivers, and the UI/runtime bridge. Apps interact with the system exclusively through the App SDK and broker services.
- Storage initializes, creating the base writable layout for
/dataand/libbefore any settings or UI work. - Settings load from
/data/settings.jsonunder schema v2 rules. If only/data/settings.json.tmpexists, it is promoted andSETTINGS_RECOVEREDis emitted. /sdmount is attempted before app discovery.- Runtime path wiring for
/liband/sd/libhappens before any MicroPython app imports. - Boot mode is
normalonly when/sdis mounted and recovery is not forced. Otherwise boot mode isrecovery. - The native broker initializes OLED, RTC, SD, network, and keypad services before discovery.
- App discovery runs after broker init. Recovery mode exposes only the built-in entries; normal mode adds discovered SD apps and counts rejected packages.
SYSTEM_READYis emitted before launcher handoff.
Persistent state is split between one JSON file and NVS namespaces:
/data/settings.json(schema v2, with/data/settings.json.tmpas the staging/recovery file) holds exactly:schema_version,policy.wifi.preferred_ssid,policy.wifi.password, andpolicy.recovery.force_recovery.- Everything else lives in NVS and NVS is authoritative for it:
jpp_time(NTP enable/host/timezone),jpp_power(dim/poweroff seconds),jpp_webdav(password mode + static password),jpp_sound(buzzer volume, startup jingle),jpp_user(username). Limited Run Verification data is the exception: it lives on the external AT24C32 EEPROM, not in NVS or settings.json, so it survives factory reset and reflash.
Rules:
schema_versionis2. Any other schema — or a corrupt/non-dict payload — is treated as corrupt:SETTINGS_RESETis emitted, defaults are rewritten, and boot continues. There is no migration path.- Writes to settings.json are atomic: staged to
/data/settings.json.tmp, then renamed over the main file. Temp-file recovery promotes the staging file when it is the only settings file present, so a failed write never loses the last complete payload. Settings > Backup settingsserializes the NVS namespaces plus settings.json into one backup file under/sd/backups/; restore writes them back and restarts.
| Logical path | Owner | Contract |
|---|---|---|
/data |
Native storage layer | Flash-backed writable state for settings, grants (/data/grants/<app_id>.json), and the crash log (/data/ui_crash.log). Maps to the ESP-IDF data_fs mount. |
/lib |
Native runtime layer | System-owned shared library/runtime path. Maps to the ESP-IDF runtime_fs mount. |
/sd |
Removable media layer | Removable app/media root; absence forces recovery-compatible behavior. A distinct removable mount, never a flash alias. |
/sd/apps/<dir> |
App packaging | Manifest-driven app package root. Packages are discovered only under this subtree. |
/sd/lib |
Runtime layer | Optional shared SD library root; system-owned, not a promise of unrestricted imports. |
/sd/apps/<app_id>/ |
App SDK (scoped storage, ungated) | App-private writable subtree on SD. file_read() / file_write() / file_list() paths are rooted here; the KV helper persists to .kv.json inside it. |
/sd/shared/<app_id>/ |
App SDK (shared storage, ungated) | Per-app subdirectory within the shared SD area; any app can read another app's shared subtree through shared_*(). |
/sd/ipc/<recipient>/<sender>/ |
App SDK (IPC, ungated) | Mailbox files (msg_<tick>.json) written by ipc_send() and consumed (then deleted) by the recipient's ipc_recv(). |
data_fsis the flash-backed ESP-IDF VFS mount that backs/data.runtime_fsis the flash-backed ESP-IDF VFS mount that backs/lib./sdis mounted independently from removable media and is never folded into either flash mount.
- Manifest and SDK-relative package paths must be relative, must not start with
/, and must not contain..traversal segments. - Package entry paths are resolved relative to
/sd/apps/<dir>and must stay within the app root. - Scoped paths are rooted at
/sd/apps/<app_id>/and shared paths at/sd/shared/<app_id>/; the SDK builds the absolute path itself, so path construction alone enforces the sandbox root. files.fullSDK paths must be absolute under/sd/with no..traversal; each path requires per-open user approval.- Any path outside these rooted logical spaces is rejected rather than normalized into a broader filesystem escape.
Two kinds of SDK surface exist:
- Ungated — available to every app with no manifest declaration and no consent tracking: frame/canvas rendering, key input, dialogs (
dialog/list/input), buzzer, wakelock, scoped storage, shared storage, the KV helper, IPC,device_status(), andget_time(). The firmware enforces scoping (sandbox roots, budgets), not permission. - Declared — must be listed in the manifest
capabilitiesarray and consented by the user on first use (lazy / per-use prompts viajpp_sdk_ensure_cap):- Tier 1 (prompted once, persisted to
/data/grants/<app_id>.json):http.request,https.request,ble.scan,ble.advertise,background.register,esp_now. - Tier 2 (prompted on first use every launch, never persisted):
files.full,network.bind,network.connect,ble.connect,ble.host.
- Tier 1 (prompted once, persisted to
Two capabilities add a second, resource-scoped prompt on top of the capability prompt, because the capability alone does not identify what is being reached: files.full prompts per absolute path (never persisted) and https.request prompts per origin (scheme://host[:port], persisted one-per-line to /data/grants/<app_id>.origins — a separate file, since the .json grant store is rebuilt from the fixed Tier-1 list and would drop anything else kept there).
A denied capability stays out of the broker caller list; the broker is the final authority. Every denial is logged (CAP_DENIED for user-declined or undeclared capabilities, ACCESS_DENIED from the broker gate).
| Resource | Native owner | App-visible path |
|---|---|---|
| OLED + shared I2C bus | Broker serializes access and owns rendering | Apps update text through SDK frame APIs, not driver handles. |
| RTC/time, battery | Broker owns time reads and ADC reads | device_status() returns battery state; get_time() returns RTC time. Both ungated. |
Files on /sd (scoped/shared) |
Broker validates logical paths and mediates reads/writes under the storage lock |
file_*() rooted at /sd/apps/<app_id>/; shared_*() rooted at /sd/shared/<app_id>/. Ungated. |
Files on /sd (full) |
Broker validates absolute paths; each open requires user approval | files.full gates file_open() / handle_*() / file_pick(). Handles are valid for the app session. |
| HTTP client | Broker serializes requests under the http lock |
http.request gates cleartext http_request(); https.request gates TLS-verified https_request(). Both share the one http lock, so a single request is in flight at a time. |
| TCP sockets | Broker serializes socket calls under the network lock; lwIP callbacks live in main/jpp_native_services.c |
network.bind gates the server side (net_bind()/net_accept()); network.connect gates the outbound client side (net_connect()). net_recv()/net_send()/net_close() accept a socket from either. 1 listener + 2 connections; binding refused while WebDAV or the LRV server runs; all sockets closed at app teardown. |
| BLE radio | Broker serializes radio, client, and host access under the ble_radio / ble_client / ble_host locks |
ble.scan / ble.advertise / ble.connect / ble.host gate the corresponding ble_*() calls. |
| Keypad + launcher focus | Launcher/system UI owns input polling | Apps receive normalized actions and lifecycle callbacks, not raw keypad driver access. |
| Recovery/settings policy | Native core + Settings UI only | Runtime apps do not mutate recovery policy directly. |
Named broker locks: storage, device, vm_queue, ble_radio, ble_client, ble_host, http, network.
- The launcher is a text-first SSD1306 shell with a root screen, built-in Settings and WebDAV entries, line-based rendering, and crash recovery back to the launcher.
- Rendering is line-based and satisfies an eight-line frame contract.
- Settings is a built-in system UI entry available in both normal and recovery mode.
- App failures (MicroPython runner errors, native load failures) emit
APP_CRASH, append a line to/data/ui_crash.log, and surface a crash dialog after teardown; the shell keeps running.
- Exactly one embedded VM exists for MicroPython apps.
- The native core never calls app objects from arbitrary RTOS tasks; lifecycle hooks are marshaled onto the shared VM task.
- Exactly one app may own the foreground at a time.
- Background work is opt-in and never concurrent with a foreground app: scheduled tasks (manifest
background.tasks+ persistedbackground.registergrant) run as separate headless sessions while the device idles on the launcher. - Built-in system UI flows such as Settings are native/system-owned and do not run inside the shared VM.
The budget slice is defined in components/jpp_core/include/jpp_resource_budget.h and enforced at the points named below; host probes under tests/ assert the same numbers.
| Area | Target | Limit / floor | Failure behavior |
|---|---|---|---|
| VM heap | 131072 bytes |
reject configs above 196608 bytes |
jpp_vm_runtime_prepare() returns INVALID_CONFIG. |
| VM stack | 8192 words |
reject configs above 12288 words |
jpp_vm_runtime_prepare() returns INVALID_CONFIG. |
| VM queue | 4 requests |
reject configs above 8 requests |
Oversubscription returns QUEUE_FULL; over-limit config returns INVALID_CONFIG. |
| Native free heap after boot | floor of 65536 bytes |
jpp_heap_monitor warns at 30 KB and errors at 15 KB free |
Failed allocations are logged with size/caps/function (ALLOC FAILED, tag heap_mon). |
| SDK frame bridge | 7 lines |
7 lines |
Extra frame lines return TEXT_TRUNCATED. |
| Background schedule | 4 tasks per manifest |
8 schedule entries across all apps |
Extra manifest tasks reject the manifest (INVALID_BACKGROUND); a full schedule table drops the overflowing entry with a BG_SCHEDULE full log. Each headless run is killed after 30000 ms (BG_TASK_KILLED, device restart); intervals below 60 s reject the manifest. |
SDK file handles (files.full) |
2 open handles |
4 open handles |
Further file_open() calls return HANDLE_LIMIT. |
SDK BLE connections (ble.connect) |
1 open connection |
2 open connections |
Further ble_connect() calls return BLE_CONN_LIMIT. |
| Broker result fields | 4 fields |
8 fields |
Further fields return FIELD_OVERFLOW. |
| Broker locks | 4 named locks |
8 named locks |
Further resources return FIELD_OVERFLOW. Named locks in use: storage, device, vm_queue, ble_radio, ble_client, ble_host, http, network. |
- Runtime apps have a restricted import surface. The native bridge exposes explicit SDK modules without opening raw hardware modules.
- Apps do not get direct hardware, unrestricted OS, raw network, or arbitrary filesystem access.
- The broker is the final authority on file, time, and network access; declared capabilities are checked inside the shared VM and the bridge.
- App-private file access is rooted at
/sd/apps/<app_id>/and shared access at/sd/shared/<app_id>/— both enforced by path construction. Arbitrary SD access (files.full) requires per-path user approval at open time. - This is a best-effort sandbox for product stability and policy enforcement, not a malicious-code containment boundary.
App packages are discovered from /sd/apps/<dir>/manifest.json. Each package must include a compiled entry module and a valid manifest.
| Field | Type | Rule |
|---|---|---|
schema_version |
int | Must be 2. |
app_id |
string | Stable unique app identifier; reserved ids (launcher, settings, webdav, webdav_passconfig, shell, dialog, app_crash, sd_ejected) are rejected. |
name |
string | Launcher label. |
version |
string | App package version string. |
sdk_min |
int | Minimum SDK API level the app requires (≥ 1). The loader rejects an app whose sdk_min exceeds the firmware's JPP_SDK_VERSION (SDK_TOO_OLD). |
entry |
string | Relative compiled entry path inside the app root; no leading / and no .. traversal. .mpy for MicroPython apps, .bin for native apps. |
capabilities |
list[string] | Only the eleven prompted capabilities are declarable — Tier 1: http.request, https.request, ble.scan, ble.advertise, background.register, esp_now; Tier 2: files.full, network.bind, network.connect, ble.connect, ble.host. Anything else is INVALID_CAPABILITY. |
background.enabled |
bool | Enables or disables background execution for the app. |
background.tasks |
list | Up to 4 tasks, each {name, interval_s} or {name, cron} — exactly one schedule source per task. interval_s ≥ 60; cron is 5-field min hour dom mon dow, each field * or a single in-range number. Violations reject the manifest (INVALID_BACKGROUND). |
toolchain.runtime_version |
string | MicroPython apps only: exact runtime version pin. |
toolchain.cross_version |
string | MicroPython apps only: exact mpy-cross version pin. |
toolchain.bytecode_abi |
int | MicroPython apps only: bytecode ABI version; mismatch rejects the app before launch. |
entryis a relative package path; leading/and..traversal are rejected.manifest.jsonis the discovery source of truth for the app directory.entry_pathis rooted under/sd/apps/<dir>and derived from the manifest, not provided as an absolute path.- App entrypoints are compiled modules (
.mpybytecode or.binELF objects). Uncompiled source files are not loaded. - MicroPython packages that omit toolchain metadata are rejected; native packages carry no toolchain block.
- The native bridge currently exports SDK version
3(JPP_SDK_VERSION) ondevelop. Level2is the newest level in a released firmware (v1.1); level3is unreleased and still open.sdk_minis a floor: the loader rejects an app whosesdk_minexceeds the running SDK version (SDK_TOO_OLD). There is no upper bound — an app built for an older SDK keeps running on newer firmware (additive, backward-compatible surface). Level2added outbound TCP (net_connect, capabilitynetwork.connect), TLS-verified HTTP (https_request, capabilityhttps.request), and the crypto primitives (jpp_crypto_sha256/sha1,jpp_crypto_aes256_ige_encrypt/_decrypt,jpp_crypto_modexp/rsa_encrypt/dh_compute). Level3madejpp_sdk_wrap_text()resolvable (declared since v1.0-RTM but missing from the native symbol table), gave MicroPython bindings to sixteen calls that used to be C-only (see below), and renamed the level-2 CENTER-claim API to OK (jpp_sdk_claim_center/jppsdk.claim_center/JPP_SDK_KEY_CENTER*/JPP_SDK_CENTER_CLAIM_*→jpp_sdk_claim_ok/jppsdk.claim_ok/JPP_SDK_KEY_OK*/JPP_SDK_OK_CLAIM_*). Unlike every other level-3 addition this is a rename rather than a pure addition, but the old names are kept as deprecated aliases — sames_symtabentry / module-dict object as their replacement — so nothing already built needs to change; new C source using an old name gets a-Wdeprecated-declarationswarning, MicroPython does not.sdk_minis unaffected for code still using the old names (claim_centerstays a level-2 requirement); only the newclaim_okspelling needs level 3. - Level
2shipped in firmware v1.1 and is closed: folding a further addition into it would leavesdk_min: 2meaning two different surfaces in the field. Level3is unreleased and open — further backward-compatible additions before the next release belong in level3rather than minting level4. A level is closed by a release, not by being merged. - Available from both C and MicroPython (
jppsdkmodule):set_frame(),request_close(),log(),dialog(),list(),input(),poll_key(),wait_key(),canvas_write(),canvas_clear(),canvas_draw_pixel(),canvas_fullscreen(),buzzer_tone(),buzzer_play(),buzzer_play_sequence(),buzzer_play_sequence_async(),buzzer_stop(),led_set_color(),led_off(),wakelock_acquire(),wakelock_release(),device_status(),get_time(),is_dummy_mode(),file_read(),file_write(),file_list(),shared_read(),shared_write(),shared_list(),file_open(),handle_read(),handle_write(),handle_list(),handle_close(),background_register(),http_request(),https_request(),net_bind(),net_accept(),net_connect(),net_recv(),net_send(),net_close(),ipc_send(),ipc_recv(),kv_get(),kv_set(),kv_delete(),ble_scan(),ble_advertise_start(),ble_advertise_stop(),ble_connect(),ble_read_char(),ble_write_char(),ble_disconnect(),ble_service_register(),ble_service_unregister(),espnow_send(),espnow_recv(),claim_ok(),request_cap(),confirm(),wrap_text(),file_pick(),ble_set_connectable(),ble_host_set_value(),ble_host_wait_write(),ble_host_clear(),crypto_sha256(),crypto_sha1(),crypto_aes256_ige_encrypt(),crypto_aes256_ige_decrypt(),crypto_modexp(),crypto_rsa_encrypt(),crypto_dh_compute()— the last eight (request_capthroughcrypto_dh_compute) only gained their MicroPython bindings at level3; in C they have been callable since level1(or level2for the crypto primitives).claim_ok()itself is level-3-only under that name — it existed at level2asclaim_center()/jppsdk.claim_center, which is still there too, deprecated (see above). - C-only: the code-module surface
jpp_sdk_module_load()/jpp_sdk_module_run()/jpp_sdk_module_unload()(native apps only — MicroPython hasimportinstead). This is now the only native/MicroPython gap in the App SDK surface. - The
dialog(),list(), andinput()helpers are the standard high-level UI prompts (message box, selection list, on-screen text/value entry) and are ungated. Modal helpers with a title draw the shared "signature line" rule on page 1 (matching the launcher/settings/WebDAV header).input()withINPUT_DATE/INPUT_TIMErenders a field spinner (LEFT/RIGHT pick field, UP/DOWN adjust) with123(masked manual entry),now(RTC), Cancel, and OK buttons; it returnsYYYY-MM-DDfor dates andHH:MM:SS(seconds included) for times. device_status()returnsbattery_pctandcharging(-1= unknown; the board has no charge-detect line);get_time()returns"YYYY-MM-DD HH:mm". Both ungated.kv_get()returns a non-OK status when the key is absent (so a get after a delete reports "not found" rather than an empty value).ipc_recv()reports an empty mailbox asNO_DATA(MicroPython: returnsNone).log()writes the event to the serial console under tagapp_log.file_read()/file_write()/file_list()use app-local relative-path semantics rooted at/sd/apps/<app_id>/.shared_read()/shared_write()/shared_list()use app-local relative-path semantics rooted at/sd/shared/<app_id>/.file_open()takes an absolute/sd/path, prompts the user for approval, and returns a session-scoped handle.- Adding a new privileged surface or changing an existing method's behavior in a breaking way requires an SDK version bump and manifest range validation.
- App discovery lists every
/sd/apps/<dir>with amanifest.jsonand a non-reserved id; rejected directories are counted and logged. - MicroPython apps export
create_app(sdk)— the MicroPython runner calls it once and dispatches lifecycle hooks (on_start,on_stop,on_idle,handle_action) on the returned object. - Native apps export
void jpp_app_entry(jpp_sdk_context_t *ctx)— the ELF loader (jpp_native_loader_core) resolves and calls it directly; the function runs synchronously with no separate lifecycle hooks. BACKrequests close; the session is torn down when the app's entry returns orrequest_close()is called.- Opening an app that already has a live session foregrounds the existing session instead of starting a second copy.
- Schedules are declarative: the manifest's
background.tasksis the only schedule source.jpp_sdk_background_register()/jppsdk.background_register()triggers thebackground.registerconsent prompt (Tier 1); while that grant is persisted, the firmware syncs the app's tasks into/data/bg_schedule.jsonat every app exit and removes them when the grant or manifest entry disappears. - Due tasks run only while the device is idle on the launcher — no foreground app, no WebDAV or LRV server, no serial session. A user launch preempts a running task (
BG_TASK_PREEMPTED). - Headless runs reuse the normal app session machinery without UI: MicroPython runs the module-level
on_task(name); native apps run the optionaljpp_app_task_entry(ctx, name)export (a missing export logsNO_TASK_ENTRY). Consent prompts are denied during headless runs (CONSENT_HEADLESS_DENY), so only launch-time persisted Tier-1 grants are usable. - Interval tasks fire when
now >= last_run + interval_s; cron tasks fire once within the matching minute.last_runis recorded before the run starts, so a crashing task cannot re-fire in a loop. The scheduler reads the DS1307 RTC and only ticks while the device is awake; deep sleep pauses it and missed cron minutes are not replayed. - A run that exceeds
JPP_RESOURCE_BG_TASK_RUN_QUOTA_MS(30000ms) is terminated by a device restart (BG_TASK_KILLED) — a mid-run task kill could leak broker locks or the app pool, so the firmware restarts instead of deleting the task.
| Failure class | Marker / code | Required response |
|---|---|---|
| Bad package at discovery | APP_REJECTED <dir>: RESERVED_APP_ID or manifest.json missing |
Reject only the bad directory, count it in rejected, keep booting, and still reach the launcher. |
| Bad manifest / entry at launch | SD_APP_LAUNCH <id>: preflight <result> where result is MANIFEST_REJECTED (with the manifest validator code, e.g. SCHEMA_MISMATCH, INVALID_ENTRY, INVALID_CAPABILITY, RUNTIME_MISMATCH, SDK_TOO_OLD), MISSING_ENTRY, INVALID_ENTRY, INVALID_TOOLCHAIN, or CORRUPT |
Refuse the launch; the launcher stays alive and the catalog stays available. |
| Unsupported import | UNSUPPORTED_IMPORT (VM status) |
The import is refused inside the shared VM; the app fails, the launcher does not. |
| App failure at runtime | APP_CRASH app=<id> reason=<reason> |
Append a line to /data/ui_crash.log, tear down the session, and show the crash dialog before normal launcher flow resumes. |
| Background task failure | BG_TASK_ERROR (runner/loader error) or NO_TASK_ENTRY (native export missing) |
Log only — no crash dialog for headless runs; the session is torn down and the launcher is unaffected. |
| Background task overrun | BG_TASK_KILLED (quota exceeded) |
Restart the device; last_run was recorded at launch, so the task does not re-fire immediately after boot. |
| Capability denial | CAP_DENIED (user declined / not declared) or ACCESS_DENIED (broker gate) |
Return an error result to the app, log the denial, and do not grant the privileged action. |
| SD removal during runtime | SD_EJECTION_DETECTED then SD_EJECTED |
The shell switches to the fatal SD-ejected screen; a reboot (with media restored) recovers. |
| Settings corruption | SETTINGS_RESET |
Rewrite defaults and continue boot; never brick on a bad settings file. |
| Settings write interrupted | SETTINGS_RECOVERED |
Promote /data/settings.json.tmp to the main file on next boot. |
OTALVGL redesignmulti-VMmulti-foreground-app