Firmware v1.1 · 2026-07-29 — this snapshot marks the v1.1 release, the first feature release after the v1.0-RTM (release to manufacturing) milestone. It does not exempt later edits from the freshness rule below: any doc touching a release milestone marker should update it too.
These project documents — every AGENTS.md, README.md, and all files under docs/ —
MUST be updated in the same change as any code or behaviour they describe.
This is non-negotiable and non-deferrable: if you change the App SDK surface,
broker policy, capability tiers, manifest schema, settings schema, boot
behaviour, hardware mapping, or any other documented contract, update the
relevant docs in the same commit. Out-of-date documentation is treated as a
defect, not a follow-up.
The human-facing app developer docs live in docs/: docs/index.md (overview),
docs/manifest.md (manifest schema), docs/sdk-reference.md (SDK index) plus the
per-category pages under docs/sdk/ (types, app-control, display, hardware,
storage, network, wireless, crypto, background, limits),
docs/sdk-changelog.md (what each SDK API level added — update it in the same
commit as any JPP_SDK_VERSION bump), docs/serial-protocol.md,
docs/micropython/getting-started.md, docs/native/getting-started.md,
and docs/native/modules.md. Keep these in sync with the SDK surface in components/jpp_core/.
These pages are MkDocs-first: they use !!! note/info/success/warning/danger
admonitions and /// tab | C … /// tab | MicroPython blocks for C/Python
signature pairs, which render as raw text on GitHub. Do not "fix" that by
reverting to plain Markdown — the published site at
https://jppdevice.by.m4l3vi.ch/sdk-docs/ is the delivery surface. Two rules the
mkdocs-shadcn theme imposes, both easy to break silently:
(1) a !!! type "Title" title is rendered inline with the first body
paragraph, so every title must end in terminal punctuation or it runs into the
body mid-sentence; (2) only note/info/success/warning/danger have
real styling — any other type falls back to a plain note box with no warning.
JPPDOS is an ESP-IDF C/C++ firmware repo for ESP32-C6-class hardware. Native boot, storage, settings, broker policy, UI, and hardware services live in components/jpp_core/; the build and flash flow is container-friendly and centered on idf.py plus Docker.
jppdos/
├── components/jpp_core/ # native core implementation
├── main/ # ESP-IDF app entrypoint + settings screen
├── apps/ # example/reference app packages (meetapp, games, demoscene, testapp_native, testapp_mp); apps/common/ holds shared app-side helpers compiled into each app (e.g. jpp_ble_msg)
├── docs/ # human-facing app developer documentation (index, guides, SDK reference)
├── scripts/ # flash and helper scripts
├── tools/ # developer tooling (docs: MkDocs site image)
├── tests/ # host-side checks and fixtures
└── wokwi/ # simulator assets and reference topology
| Task | Location | Notes |
|---|---|---|
| Core boot and services | components/jpp_core/ |
startup, settings, broker, storage, UI, and device policy |
| Build flow | idf.py, Dockerfiles/scripts |
native build, target selection, and reproducible container runs |
| MicroPython source | components/micropython/ |
Fetched, not vendored — there is no submodule and no checked-in copy. CMakeLists.txt pulls a pinned GitHub source archive via CMake FetchContent at configure time, verifies it against MP_ARCHIVE_SHA256, and unpacks it to .deps/micropython-<commit>/ (gitignored, deliberately outside build/ so idf.py fullclean doesn't force a refetch). Only py/ and shared/runtime/ are compiled — nothing from lib/, extmod/, or ports/ — which is why an archive suffices: it carries no submodules, and 8 MB replaces the 1.6 GB checkout + 2.2 GB of .git/modules the submodule cost. A first configure needs network; after that it is cached. The fetch is guarded by if(NOT CMAKE_SCRIPT_MODE_FILE) — ESP-IDF resolves component dependencies in an early pass that re-runs every component CMakeLists.txt under cmake -P, and FetchContent_Declare reaches define_property, which script mode does not provide; without the guard the whole configure aborts before a single file compiles. That pass reads only REQUIRES (idf_component_register ignores SRCS there), so skipping the download in it is safe. Keep any new configure-time fetching behind the same guard. -DMP_SOURCE_DIR=/path/to/micropython points the build at a local checkout instead (for bisecting upstream). To move the pin, change MP_COMMIT and MP_ARCHIVE_SHA256 and MP_GIT_TAG/MP_GIT_HASH (the last two reproduce what git describe would yield, since an archive has no .git — they are handed to makeversionhdr.py through its MICROPY_GIT_TAG/_HASH env overrides), and keep mpy-cross in step. The pin is a v1.29.0-preview commit (1c63211), not the v1.28.0 release the docs elsewhere name — that mismatch predates the FetchContent switch and was carried over unchanged |
| App build toolchain | separate jppdos-apps repo (toolchain/) |
The jppd-app-sdk Docker image + jppd-build entrypoint no longer live here — they moved to the sibling apps repo. That repo does not vendor this one — there is no submodule; toolchain/build-image.sh over there resolves the tip of this repo's master (or any JPPDOS_REF) with git ls-remote and passes the commit to the Dockerfile, which clones it in its builder stage. Resolving the SHA outside docker is deliberate: it invalidates the clone layer whenever master moves, so "latest" cannot silently rot in the layer cache, while still pinning each image to one exact commit (recorded at /opt/jppd-sdk/firmware-rev in the image and in the org.jppdevice.firmware-rev label). The clone reads from GitHub (https://github.com/jppteam/jppdos.git), this project's primary host — so anything you want the SDK image to pick up must be pushed there; the git.nova.tokyo mirror is never consulted. That image bakes a firmware build as an SDK sysroot, so app developers need no firmware checkout at all. capture_sysroot.py still reads this repo's build/compile_commands.json + generated headers, so any change to the SDK surface, component include dirs, or apps/common/ means the image must be rebuilt over there. Only the toolchain moved — apps did not. The App SDK test apps (testapp_native, testapp_mp) went over with it initially and have since come back here, so that showcase apps rebuild against the SDK on every idf.py build instead of silently rotting behind it; apps/mtproto/ went the other way, to jppdos-apps. The rule of thumb: an app whose job is to demonstrate or exercise the SDK belongs here, where a breaking SDK change breaks its build; a standalone app belongs over there |
| Docs site | mkdocs.yml (root) + tools/docs/ |
MkDocs + mkdocs-shadcn theme renders docs/ in place as a searchable static site (Python-only, no Node). docs/ stays the single source of truth — the site adds nav/theme/search only. docs/sdk-expansion.md is excluded (firmware-internal). Build/preview via the jppd-docs Docker image; mkdocs serve mounts the site under the site_url path, so the local preview is at http://localhost:8000/sdk-docs/, not /. mkdocs build --strict validates every intra-doc link and is the check to run after touching docs. The markdown_extensions list is theme-constrained, not free choice: codehilite (not pymdownx.highlight/superfences — the theme's CSS targets the .codehilite wrapper) and pymdownx.blocks.tab without alternate_style (the theme styles only the legacy radio-input tab markup and ships no JS for the button variant). Adding a new page under docs/ requires a nav: entry or it won't appear in the sidebar. Published by .github/workflows/docs.yml to https://jppdevice.by.m4l3vi.ch/sdk-docs/ — on every push to master touching docs//mkdocs.yml/tools/docs/, the MkDocs build is synced to the sdk-docs/ prefix of the docs bucket (Yandex Object Storage; config via the S3_BUCKET/S3_ENDPOINT_URL/AWS_REGION repo variables + S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY secrets, fed to the AWS CLI as env vars — aws-actions/configure-aws-credentials is unusable here because it always validates against AWS STS). PRs build but don't publish. site_url in mkdocs.yml must match the published location or sitemap.xml comes out empty. The sibling jppdos-apps README deep-links this site, so a URL change breaks links over there |
| Tagged releases | .github/workflows/release.yml |
Pushing a version tag (1.1, v2.0, …) builds the firmware in the stock espressif/idf:v5.5.1 image and publishes a GitHub Release with the assets attached: a merged single-file image (jppdos-<tag>-esp32c6-merged.bin, one esptool write at 0x0), a zip of the separate bootloader/partition-table/app images plus flasher_args.json, a zip of build/apps/, and SHA256SUMS.txt. No secrets — the built-in GITHUB_TOKEN creates the release. Release channel is derived from history, not from the push: a tag push carries no branch (github.ref is only refs/tags/<name>), so the workflow asks whether the tagged commit is reachable from master (⇒ full release) or from develop (⇒ pre-release, --prerelease plus a banner naming the branch and short SHA in the body). master is tested first, because once develop merges the commit is contained in both and the tag is a real release; a commit reachable from neither branch is also published as a pre-release, with a warning — erring toward "not final" is recoverable (promote it in the UI) where a premature "Latest release" is not. A tag containing rc/alpha/beta is a pre-release regardless of branch. The branch names are the STABLE_BRANCH/PRERELEASE_BRANCH env vars at the top of the file. Detection lives in the build job and is exposed as a job output, because the version gate below needs it too. The build job has no job-level container: — GitHub Actions pulls a job container before any step runs, which forecloses caching it via a preceding step, so instead each toolchain step (mpy-cross, idf.py build, packaging, size summary) writes its own script to $RUNNER_TEMP and runs it with an explicit docker run against IDF_IMAGE (a workflow-level env var, currently espressif/idf:v5.5.1) — each step is a fresh container, sharing nothing but the bind-mounted host paths, which is why the mpy-cross binary is written to $RUNNER_TEMP rather than /usr/local/bin. The image itself comes from an actions/cache tarball restored at the top of the job (falling back to a real docker pull + docker save on a miss). Because GitHub scopes a cache to the ref that created it plus a fallback to the default branch only — never tag-to-tag — a cache saved during a tag-triggered run here would only ever help a retry of that same tag; .github/workflows/cache-idf-image.yml is what actually keeps the cache warm for the next tag, by pulling and saving the same image (read out of this file's IDF_IMAGE line, so the two cannot drift) on pushes to master — the one scope every tag, whichever branch it was cut from, falls back to. Three gates worth knowing about: the host pytest suite must pass first (a tagged build is the one that must not ship broken); JPPDOS_VERSION in main/jpp_settings_screen.h must equal the tag — fatal for a stable release, so one can never self-report a different version in Settings > About than the tag it came from, but only a warning for a pre-release, since 1.2-rc1 (or 1.2 on develop) is routinely tagged before the header is bumped and failing there would make the pre-release channel unusable; and release notes are lifted verbatim from the matching ## <tag> section of CHANGELOG.md, falling back for a pre-release to the ## Unreleased section (a develop tag normally predates its own section) and only then to auto-generated notes — so writing that section is part of cutting a release. Every release body — whichever of those three paths it took — gets a > [!TIP] banner pointing at J++Device Manager (https://jppdevice.by.m4l3vi.ch/#/manager) prepended on top, since that's how an end user actually applies the update. The publish job applies it with a follow-up gh release edit --notes-file step after gh release create, rather than folding it into the notes composed for gh release create itself, because gh cannot combine --notes-file with --generate-notes — a uniform post-edit is the only way to cover the auto-generated-notes path too. The stock IDF image has no mpy-cross, which testapp_mp hard-fails without, so the workflow builds the same 1.28.0 pin from source, inside IDF_IMAGE, the same way the project Dockerfile does — keep the two in step. Only the production image is built: the provisioning image (CONFIG_JPP_LRV_PROVISIONING=y) carries the LRV EEPROM write path and is deliberately never published. workflow_dispatch with a tag input re-runs a failed publish. |
| Branch cleanup | .github/workflows/cleanup-branches.yml |
Deletes the feature/*/fix/* branches a full release has absorbed. Merged-ness, not the name pattern, is what authorises a deletion: the sweep is git for-each-ref --merged origin/master, so a branch that was reverted rather than merged (feature/ota-updates) — or squash-/rebase-merged, which --merged cannot see — is left alone rather than destroyed. STABLE_BRANCH/SWEEP_PREFIXES/PROTECTED_BRANCHES are env vars at the top of the file. This is the only branch sweep there is: the deletions reach the git.nova.tokyo mirror through mirror-gitlab.yml (next row), which pushes with --prune on the delete events this job raises. Triggered by workflow_run on the release workflow completing, deliberately not on: release: published — release.yml creates the release with the built-in GITHUB_TOKEN, and events raised by that token do not trigger further workflow runs, so release: published would never fire for our own releases; it then re-reads the published release's isPrerelease/isDraft with gh release view rather than re-deriving the channel. Takes a manual dry run (workflow_dispatch), defaulting to dry-run on. Note workflow_run and workflow_dispatch only fire from the default branch (master), so the file does nothing until it is merged there — unlike release.yml, which is tag-triggered and runs from the tagged commit whatever branch it lives on. |
| GitHub → GitLab mirror | .github/workflows/mirror-gitlab.yml |
GitHub is the primary host; git.nova.tokyo (GitLab) is a read-only copy fed by this workflow on every push, tag, and branch/tag deletion. It bare-clones the repo and pushes refs/heads/* + refs/tags/* with --prune, which is the whole point: GitLab's own pull mirroring is Premium/Ultimate and explicitly never removes a branch deleted upstream, so it could not propagate cleanup-branches.yml's deletions — --prune does. That is why there is no .gitlab-ci.yml: the GitLab-side cleanup job it used to hold is redundant once deletions mirror across on their own. Auth is a GitLab group access token on jppdevice with write_repository scope (no api scope needed), held as an organisation-level GITLAB_MIRROR_TOKEN secret so one token and one rotation covers every J++Device repo — group access tokens are Premium-only on GitLab.com but available at any tier on self-managed, which is what makes this work on git.nova.tokyo. This file is identical in every repo: the GitLab path is derived as jppdevice/<github repo name> rather than hard-coded, so it is copied verbatim, never edited per repo (a repo whose GitLab slug differs sets the GITLAB_PATH repository variable instead). Deliberately a --bare clone, not --mirror, which would drag GitHub's refs/pull/* across too. The refspecs are forced and nothing but this workflow should ever push to GitLab — anything committed directly over there is overwritten on the next run. |
| Flashing | scripts/flash.sh, scripts/jpp_deploy.sh |
flash.sh wraps idf.py flash monitor; jpp_deploy.sh flashes via esptool directly (reading build/flasher_args.json for offsets/flash settings) and then uploads app artifacts over JPPD-SMP in one step |
| Storage / settings | components/jpp_core/ |
/data state, schema migration, temp-file recovery; app data roots are /sd/apps/<app_id>/ (scoped) and /sd/shared/<app_id>/ (shared) |
| Broker policy | components/jpp_core/ |
capability checks, exclusive access, and service gating |
| Boot entry point | main/app_main.c |
boot sequencer, keypad task, UI render loop, power management, SD ejection |
| Hardware bring-up | main/jpp_hw_init.c/.h |
I²C init, flash/SD mount |
| Settings screen | main/jpp_settings_screen.c/.h |
full settings UI (Shutdown/Reboot, Wi-Fi, Time, Sleep timers, Sound, Controls (Back button gesture: Hold / Double-click), SD card, Backup settings, Factory Reset, * Device Info * (hidden unless LRV data present), User's name, Dummy Mode, About) |
| Settings file helpers | main/jpp_settings_load.c/.h |
file_exists, probe/write/read settings, Wi-Fi credential accessors (jpp_settings_read_wifi/jpp_settings_save_wifi) |
| Boot display | main/jpp_boot_display.c/.h |
splash screen and progress steps |
| First-boot onboarding | main/jpp_onboarding.c/.h |
welcome + username + Wi-Fi-now flow, runs once via an NVS flag; both title screens carry a time/battery status line on the last row |
| Wi-Fi init | main/jpp_wifi_init.c/.h |
NVS + esp_wifi STA mode setup |
| Native service callbacks | main/jpp_native_services.c/.h |
file I/O, HTTP, KV, RTC, SD-ejection flag, path-prompt callbacks |
| App lifecycle | main/jpp_app_dispatch.c/.h |
app discovery, consent, launch, teardown; lazy consent via apply_consent() + jpp_sdk_set_pending_caps(); sd_app_task_fn sets close_requested on exit so the main loop can tear down |
| Background scheduler | main/jpp_bg_scheduler.c/.h |
schedule table for manifest background.tasks (interval/cron); persists /data/bg_schedule.json; due-check + mark-run consumed by the main loop |
| Serial manager | main/jpp_serial_mgr.c/.h |
JPPD-SMP binary protocol over the native USB-Serial-JTAG peripheral: SD file management, device-info queries, LRV data retrieval, RTC time sync, and (provisioning builds only) LRV EEPROM provisioning from a host PC; requires user consent before session opens |
| First-boot onboarding | main/jpp_onboarding.c/.h |
one-shot welcome flow (NVS jpp_onboard/done): unit-serial dialog, optional username prompt, Wi-Fi-now Yes/No, plus a clock/battery status line on the last row of the two title screens; called from run_main_loop() right after load_username() |
| LRV data | main/jpp_lrv.c/.h |
Limited Run Verification AT24C32 EEPROM storage and crypto: the raw identity record lives on the external EEPROM (0x50, write-once), not NVS, and is stored unencrypted — no password, no unlock step; sign challenges with device Ed25519 key, supply display info and full data for the verification server. jpp_lrv_init(bus) binds it to the I²C bus at boot and reads the record into RAM |
| LRV server | main/jpp_lrv_server.c/.h |
HTTP verification server on port 3000; serves certificate hex dump and challenge-response; "Open Certificate Page" is a direct link to https://jppdevice.by.m4l3vi.ch/verify?serial=...&cert=...&certsig=...&challenge=...&resp=...; mutually exclusive with WebDAV; requires an LRV identity to be present |
| Wokwi reference | wokwi/ |
simulator topology only, not hardware sign-off |
| Host-side tests | tests/ |
python3 -m pytest tests runs everything (CI does the same): test_contract.py (ESP_IDF_CONTRACT.md structure + scope), test_manifests.py (repo apps + fixture corpora against validate_manifests.py, a self-contained mirror of jpp_manifest_core + loader preflight — keep in sync with the C rules), test_keypad.py (OK gesture timing — tests/keypad_harness.py compiles the real jpp_keypad_core.c with cc and drives it over ctypes, one 20 ms poll at a time; skips only if no C compiler is present). The keypad harness is the pattern to copy for any other pure jpp_core state machine: no ESP-IDF needed, since jpp_keypad_core.c includes nothing but <string.h>. test_httpd.py extends that pattern one step further: tests/httpd_harness/ compiles the real jpp_http_server_core.c + jpp_fileserver_core.c + jpp_app_pool.c against a ~90-line pthread shim for FreeRTOS (stub/) and drives them over loopback with real HTTP bytes — request framing, keep-alive, chunked vs fixed-length bodies, Expect: 100-continue, a 100 KB PUT/GET byte-comparison, the WebDAV verbs (OPTIONS/PROPFIND/GET/HEAD/PUT/MOVE/MKCOL/DELETE), Basic auth, .. traversal rejection, and the app-pool acquire/release interlock. Same skip-if-no-compiler rule. test_sdk_abi.py pins the SDK ABI (frozen jpp_sdk_native_services_t field list, append-only jpp_sdk_context_t prefix) and cross-checks the C capability whitelist + JPP_SDK_VERSION against the Python mirror — both guard defects that already shipped once |
| Symbol | Type | Location | Role |
|---|---|---|---|
jpp_boot_core |
component | components/jpp_core/ |
boot ordering, readiness, recovery decisions |
jpp_settings_core |
component | components/jpp_core/ |
settings schema, normalization, recovery |
jpp_broker_core |
component | components/jpp_core/ |
capability gate and exclusive resource access |
jpp_vm_core |
component | components/jpp_core/ |
shared VM scheduling and runtime isolation |
jpp_sdk_bridge |
component | components/jpp_core/ |
App SDK surface: frame, file I/O, buzzer, LED, wakelock, dialog/list/confirm/input/file-pick UI helpers. jpp_sdk_confirm() is the shared Deny/Allow consent surface (used by capability + files.full path prompts). Titled modals draw the signature-line rule on page 1 (frame_title_rule). jpp_sdk_input with INPUT_DATE/INPUT_TIME is a field spinner (LEFT/RIGHT field, UP/DOWN value) with 123/now/Cancel/OK buttons; returns YYYY-MM-DD / HH:MM:SS. jpp_sdk_kv_get returns non-OK when a key is absent; the KV helper persists to .kv.json in the app's scoped storage. Canvas: jpp_sdk_canvas_* draws to a windowed 128×48 area (pages 2–7, with frame text rows on top) by default; jpp_sdk_canvas_fullscreen(ctx, true) extends it to the whole 128×64 display (rows 0–63, pages 0–7) and hides the frame text/title rule — jpp_sdk_set_frame (and thus every modal helper) drops fullscreen to draw the system UI, and the modal helpers restore fullscreen automatically on return (via jpp_sdk_modal_done), so a fullscreen app that repaints every frame keeps rendering at 128×64 without re-calling canvas_fullscreen(true) after every permission prompt/input/dialog. jpp_sdk_buzzer_play_sequence_async plays a copied note sequence without blocking the caller (preemptive, like jpp_buzzer_play_sequence_async). jpp_sdk_led_set_color/_off (ungated) drive the onboard WS2812 pixel via jpp_led_core. jpp_sdk_espnow_send/_recv (requires esp_now, tier 1) send/receive connectionless WiFi packets via jpp_espnow_native; espnow_recv blocks up to a caller-supplied timeout and returns JPP_SDK_STATUS_NO_DATA (not an error) on timeout. jpp_sdk_module_load/_run/_unload (native apps only) page a second ELF from the app's own scoped storage into the app-pool tail — see jpp_native_loader_core. SDK v2 (JPP_SDK_VERSION = 2, manifest sdk_min: 2): jpp_sdk_net_connect (requires network.connect, tier 2) opens an outbound TCP client socket reusing the existing net_recv/net_send/net_close (those now accept a socket from either network.bind or network.connect); the ungated crypto primitives jpp_crypto_sha256/sha1, jpp_crypto_aes256_ige_encrypt/_decrypt, and jpp_crypto_modexp/rsa_encrypt/dh_compute live in jpp_crypto_core (mbedTLS-backed, HW-accelerated) — added so apps can do transport crypto (e.g. MTProto) without carrying AES/bignum in the app pool. The reference user is the mtproto minimal-client skeleton, which lives in the sibling jppdos-apps repo (not in this tree). Also in v2: jpp_sdk_https_request (requires https.request, tier 1) does TLS-verified HTTP GET/POST via esp_http_client + esp_crt_bundle_attach; it shares the broker's "http" lock with http.request. Consent is two-stage — the tier-1 cap prompt, then a per-origin prompt (scheme://host[:port], parsed and normalised by jpp_sdk_https_origin() in the bridge) persisted to /data/grants/<app_id>.origins by jpp_app_origin_prompt() in main/jpp_app_dispatch.c. Note the post-v1 callbacks live in jpp_sdk_services_v2_t at the tail of jpp_sdk_context_t, NOT in jpp_sdk_native_services_t (frozen — see the ABI rule below); install them with jpp_sdk_set_services_v2() after each bind. SDK v3 (JPP_SDK_VERSION = 3, manifest sdk_min: 3, unreleased — still open): jpp_sdk_wrap_text (ungated) is now listed in s_symtab and therefore callable from a loaded app binary. The function is unchanged and has been declared in the public header since v1.0-RTM; only its reachability is new, which is why it still mints a level — sdk_min is the only way an app can require a firmware where it resolves. Ungated surface: storage, KV, IPC, device status (now includes username), get_time, is_dummy_mode, UI/buzzer/LED/wakelock/canvas/module-load/crypto/wrap_text. jpp_sdk_is_dummy_mode(ctx) returns true when the firmware has locked the device to this app (dummy mode); apps can use this to hide their own "Exit" option. jpp_sdk_request_cap(ctx, cap) proactively fires the consent prompt for one manifest-declared capability without doing any work, so an app can front-load permission requests (ask when a mode is selected, not mid-flow); same tier/grant semantics as first-use consent, returns OK if already granted or allowed, ACCESS_DENIED otherwise. MeetApp is the reference user. jpp_sdk_claim_ok(ctx, mask) (ungated, native + MicroPython, defaults to JPP_SDK_OK_CLAIM_NONE on every bind) lets an app take over OK gestures: claim nothing and you get JPP_SDK_KEY_OK + JPP_SDK_KEY_BACK with the firmware picking which physical gesture is Back from Settings > Controls, so app code never reads the setting; claim anything (JPP_SDK_OK_CLAIM_HOLD / _DOUBLE) and the claimed gestures arrive as JPP_SDK_KEY_OK_HOLD/_DOUBLE, JPP_SDK_KEY_BACK stops being delivered, and the app owns its own exit. Claiming only HOLD also keeps JPP_SDK_KEY_OK instant (nothing then needs double-click discrimination, so no click is withheld) — that's the combination for an app where OK is a rapid action button and hold opens a pause menu. Backed by a single uint8_t ok_claim appended at the tail of jpp_sdk_context_t; resolved in keypad_task (main/app_main.c), which is the only place that sees both the user preference and the claim. JPP_SDK_KEY_BACK is an alias of the older JPP_SDK_KEY_OK_LONG (same value). Never affects UP/DOWN/LEFT/RIGHT, never affects launcher/Settings. |
jpp_native_loader_core |
component | components/jpp_native_loader_core/ |
ELF32/RISC-V PIC loader for app_type "native" binaries (entry jpp_app_entry). Also loads code modules (jpp_native_loader_load_module/_module_run/_module_free, entry jpp_module_entry(ctx, api)) into the unused tail of the app pool after the host app image, tracked by a watermark — one module at a time, the host app keeps running on any module-load failure, and a module still resident when the host app is freed is reclaimed automatically. load_image() is the shared ELF loader for both. Backs the jpp_sdk_module_* SDK surface (wrapped in main/jpp_native_services.c). |
jpp_app_pool |
component | components/jpp_app_pool/ |
single shared static .bss pool (JPP_APP_POOL_BYTES, 80 KB) for the running foreground activity: executable code for native apps, the MicroPython GC heap, or the WebDAV / LRV HTTP server (see jpp_http_server_core). jpp_app_pool_acquire_as(owner, …)/_acquire()/_release()/_in_use()/_owner(). All three are mutually exclusive — only one foreground activity at a time — so one pool serves all of them; sharing one pool instead of reserving separate exec + GC + server pools keeps the static footprint minimal, and the acquire is what enforces the exclusion rather than a policy check somewhere else. Holders that want the whole pool as one block (apps) use the acquire base pointer; holders that need several allocations (the servers) carve them with jpp_app_pool_alloc(bytes, align), a bump allocator reset on every acquire/release (jpp_app_pool_avail() reports what is left). A native app may additionally page one code module into the pool tail after its own image (see jpp_native_loader_core), so the resident footprint is host-app + one module. Leaf component (REQUIRES only log) to avoid a cycle: jpp_native_loader_core and jpp_core both depend on it. |
jpp_ui_core |
component | components/jpp_core/ |
launcher shell, WebDAV server screen, dialog/crash screens, power state tracking; jpp_ui_shell_clear_sd_apps(shell) removes all non-builtin apps from the catalog (clamps cursor) — called by background discovery before applying a fresh scan result; generic list-view helpers jpp_ui_scroll_clamp() and jpp_ui_marquee_offset() shared by the file browser, jpp_sdk_list, and the settings Wi-Fi list |
jpp_file_browser_core |
component | components/jpp_core/ |
shared file-browser state machine (sort, scroll, marquee, ".."-navigation) driven through jpp_file_browser_io_t callbacks (list_dir/render/wait_key); jpp_file_picker() in main/ and jpp_sdk_file_pick() are thin shims over jpp_file_browser_run() |
jpp_rtc_core |
component | components/jpp_core/ |
DS1307 I²C driver, datetime state, software-tick live time. The DS1307 is optional: jpp_rtc_state_init() probes the bus (i2c_master_probe) and only sets hw_attached when the chip actually answers — a board with no RTC runs clock-less (no periodic hw reads). When no hardware and no NTP sync has happened, has_datetime stays false and jpp_rtc_get_current() returns UNAVAILABLE; every clock/consumer falls back (UI shows --:--). |
jpp_eeprom_core |
component | components/jpp_core/ |
AT24C32 I²C EEPROM driver (0x50, on the RTC breakout). Like the DS1307 it is optional: jpp_eeprom_state_init() probes the bus and only marks the chip present when it ACKs. jpp_eeprom_read/_write handle the 2-byte big-endian word address, 32-byte page-boundary splitting, and the ~5 ms write cycle. Backs LRV identity storage (jpp_lrv). |
jpp_keypad_core |
component | components/jpp_core/ |
hardware-agnostic d-pad state machine (jpp_keypad_poll()) driving the resistive-ladder keypad: one ADC sample in, debounced jpp_keypad_event_t events out (PRESS/RELEASE/REPEAT/OK_SHORT/OK_LONG/OK_DOUBLE). Policy-free: it reports what the finger did and has no idea what "Back" is — a hold is always detected, and jpp_keypad_back_gesture_t is a settings type the detector never reads. Its one behavioural knob is jpp_keypad_config_t.detect_double_click: when set, a short click is withheld for double_click_ms (300 ms) so a second click can be reported as OK_DOUBLE; when clear, OK_SHORT fires the moment the button is released and OK_DOUBLE never happens. A click withheld when the flag is cleared underneath it is flushed on the next poll rather than stranded or replayed (jpp_keypad_check_pending_short()), which is what makes the mode safe to change at runtime. Driven every 20 ms by keypad_task in main/app_main.c, which re-derives detect_double_click each poll. Host-tested by tests/test_keypad.py. |
jpp_buzzer_core |
component | components/jpp_core/ |
LEDC buzzer driver; predefined sounds + custom tone/sequence API. jpp_buzzer_set_volume(percent) / jpp_buzzer_get_volume() — volume is controlled via GPIO drive capability (GPIO_DRIVE_CAP_0–3), not duty cycle; duty stays fixed at 50% (JPP_HW_BUZZER_DUTY) for all non-zero levels so waveform quality is unchanged. 0% mutes by setting duty to 0. Default after init is 100% (CAP_3). load_buzzer_volume() in app_main.c applies the persisted level before the startup chime. jpp_startup_jingle_t enum (0–10): DEFAULT, WINXP, WIN31, MAC, RICKROLL, NOKIA_ON, NOKIA_TUNE, SANDSTORM, DOOM, CLUTTERFUNK, OFF. jpp_buzzer_play_startup_jingle(jingle) plays the chosen jingle (no-op for OFF). jpp_startup_jingle_name(jingle) returns the display string. Playback comes in blocking (jpp_buzzer_tone/_play_sequence/_play/_play_startup_jingle) and async (jpp_buzzer_play_sequence_async/_play_async/_play_startup_jingle_async) forms: async copies the sequence into an inbox and hands it to a dedicated static-allocated player task, returning immediately. Submitting a new async sequence — or calling jpp_buzzer_stop() — preempts whatever is playing (generation counter checked between notes), so cycling previews cut the previous one off within one note. Blocking _play_sequence also preempts any async sequence so the two never drive the LEDC channel at once. Settings jingle previews and the boot chime use the async form. |
jpp_led_core |
component | components/jpp_core/ |
onboard WS2812 (GPIO8, single pixel) driver using the RMT TX peripheral directly (hand-rolled bit encoder, no led_strip managed-component dependency — keeps flash footprint minimal). jpp_led_init() is idempotent and also called lazily on first jpp_led_set_color()/_off(). Backs the ungated jpp_sdk_led_* SDK surface. |
jpp_espnow_native |
component | components/jpp_core/ |
ESP-NOW send/receive driver backing esp_now (tier 1). Mirrors jpp_ble_native.c: jpp_core cannot depend on main/, so this module brings up the WiFi driver itself (STA mode, idempotent — same calls as wifi_ensure_started() in main/jpp_wifi_init.c, tolerant of "already running") rather than reaching into main/. Send blocks on a semaphore signalled by the ESP-NOW send callback (bounded timeout); receive pulls from an 8-entry queue fed by the recv callback — a packet is dropped if the app doesn't drain the queue often enough. jpp_espnow_native_get_services() follows the same _get_services() out-param pattern as jpp_ble_native_get_services(). |
jpp_heap_monitor |
component | components/jpp_core/ |
global heap-pressure diagnostics: jpp_heap_monitor_init() (call once early in app_main) registers a heap_caps_register_failed_alloc_callback that logs the size/caps/function of any failed malloc (turns cryptic wifi:m f ... into an attributable ALLOC FAILED line) and starts a low-priority sampler task that WARNs below JPP_HEAP_MON_WARN_BYTES (30 KB) / ERRORs below JPP_HEAP_MON_CRIT_BYTES (15 KB) with hysteresis + a heap_caps_print_heap_info dump on first entry to CRIT. jpp_heap_monitor_log(label) emits a one-line heap @label marker for manual checkpoints (used by jpp_fileserver_core at WebDAV start/stop). |
jpp_resource_budget |
header-only | components/jpp_core/include/jpp_resource_budget.h |
runtime and broker budget limits (compile-time #define constants only) |
jpp_string_util |
header-only | components/jpp_core/include/jpp_string_util.h |
shared string helpers (jpp_str_eq/jpp_str_nonempty/jpp_str_copy) plus the shared validation predicates jpp_str_name_valid (dot-separated identifier) and jpp_str_has_parent_segment (".." traversal scan) used by manifest, VM, and SDK path checks |
jpp_file_util |
module | main/jpp_file_util.c/.h |
jpp_read_file (malloc slurp), jpp_read_file_into (fixed buffer, distinguishes open vs size errors), jpp_make_parent_dirs (mkdir -p for the dirname) |
jpp_nvs_util |
module | main/jpp_nvs_util.c/.h |
single-key NVS accessors (get_u8/i32/str with fallback; set+commit u8/i32/str; erase_key) — multi-key sections keep one open/commit/close |
jpp_draw_util |
header-only | main/jpp_draw_util.h |
jpp_draw_title (title row + signature rule) and jpp_draw_rule(page) — the shared SSD1306 screen-header style |
jpp_http_server_core |
component | components/jpp_core/ |
the minimal HTTP/1.1 server WebDAV and the LRV verification screen run on — esp_http_server is no longer linked. It exists so the servers cost the heap nothing: jpp_http_server_start(cfg) acquires jpp_app_pool and carves the server task's stack + TCB (xTaskCreateStatic), the request-header buffer, and a large shared I/O buffer out of the 80 KB; jpp_http_server_stop() waits for the task to genuinely exit (it runs on pool memory) and releases the pool. httpd_start() allocates all of that with malloc and offers no way to redirect it, which is why a WebDAV transfer used to starve the WiFi driver of frame buffers (see jpp_heap_monitor and the WiFi/heap note below). The I/O buffer is whatever the fixed carves leave, capped at JPP_HTTP_IO_BYTES_MAX (32 KB) — 8× the 4 KB static buffer it replaces, which is where the throughput win comes from; the fileserver streams files through it with raw open/read/write rather than stdio. One handler callback per server (jpp_http_handler_fn, dispatch on jpp_http_method()), one connection at a time (the I/O buffer is shared), HTTP/1.1 keep-alive, chunked and fixed-length (jpp_http_resp_begin/_write/_finish) response bodies, Expect: 100-continue, and HEAD handled by the response layer (handlers treat it as GET). Single static server instance — the two servers are mutually exclusive anyway. Host-tested by tests/test_httpd.py. |
jpp_fileserver_result_t |
enum | components/jpp_core/include/jpp_fileserver_core.h |
result codes for jpp_fileserver_*; jpp_fileserver_status_t carries ip, port, and password (up to JPP_FILESERVER_PASS_MAX chars; random JPP_FILESERVER_PASS_LEN-char or user-supplied static); use jpp_fileserver_start_with_password() for static passwords |
app_main |
entrypoint | main/app_main.c |
boot sequencer (steps 1–8), keypad task, UI render loop, power mgmt, SD ejection; dispatches JPP_VM_REQUEST_IDLE every JPP_UI_REFRESH_MS and JPP_VM_REQUEST_ACTION (with app_id) from the keypad task to running MicroPython apps; supervises background runs — launches due tasks only while idle on the launcher (no app/WebDAV/LRV/serial session), kills quota overruns via restart, and preempts a running bg task when the user launches an app (BG_TASK_PREEMPTED). Calls jpp_onboarding_run() once in run_main_loop() right after load_username(). When launch_sd_app() returns false, consumes the pre-launch failure via jpp_app_crash_take() and shows it with jpp_ui_shell_record_crash(shell, "LAUNCH_FAILED", app, reason) — same plumbing as the existing runtime "APP_CRASH" dialog, just a different title. |
jpp_hw_init |
module | main/jpp_hw_init.c/.h |
init_i2c(), mount_flash_storage(), mount_sd() |
jpp_settings_screen |
module | main/jpp_settings_screen.c/.h |
settings UI rendered directly to SSD1306; sections: Shutdown/Reboot, Wi-Fi, Time, Sleep timers, Sound (Volume, Jingle, Test — 3 rows; LEFT/RIGHT on Volume cycles level, LEFT/RIGHT on Jingle cycles startup jingle and plays a preview, OK on Test plays the selected jingle), Controls (Back button gesture — one row, LEFT/RIGHT toggles Hold/2x Click and saves immediately via do_back_gesture_change), SD card, Backup settings, Factory Reset, * Device Info * (hidden unless LRV data present), User's name (text input, persisted in NVS jpp_user/username, max JPP_SETTINGS_USERNAME_MAX = 64 chars), Dummy Mode (single-app lock; select an SD app from a scrollable list; persisted in NVS jpp_dummy/dummy_en+dummy_app_id; disabled by holding OK on boot; visible only when dummy mode is disabled — in dummy mode all launcher navigation is blocked so Settings is unreachable), About; section visibility controlled by section_is_visible() |
jpp_settings_load |
module | main/jpp_settings_load.c/.h |
file_exists(), probe_settings_payload(), write_settings(), read_force_recovery() |
jpp_boot_display |
module | main/jpp_boot_display.c/.h |
boot_disp_show_splash(), boot_disp_step() |
jpp_wifi_init |
module | main/jpp_wifi_init.c/.h |
init_wifi(), wifi_connect(), wifi_disconnect(), wifi_is_connected(), wifi_get_connected_ssid(), wifi_get_saved_ssid(), wifi_is_connecting(), wifi_ensure_started(); auto-reconnect capped at WIFI_MAX_RECONNECT_ATTEMPTS (10) — wifi_is_connecting() returns false once the limit is hit; call wifi_disconnect() to abort the loop early |
jpp_native_services |
module | main/jpp_native_services.c/.h |
jpp_native_services_init(), all I/O, BLE, and ESP-NOW callbacks, s_sd_ejection_detected. Provides the device-status callback (battery_pct/charging — call jpp_native_services_set_battery_state() from the main loop; username — read fresh from NVS jpp_user/username on every call, no caching needed), the log_writer callback (serial tag app_log), and the RTC reader (static result buffer — broker stores pointers, not copies). The path_prompt callback renders via jpp_sdk_confirm. |
jpp_app_dispatch |
module | main/jpp_app_dispatch.c/.h |
discover_apps(), launch_sd_app(), teardown_sd_app(), consent machinery; read_manifest_display_name() does a lightweight best-effort read of manifest.json's name field (falls back to the app_id/directory name if missing, unparsable, or empty) — used by both discover_apps() and bg_discover_task() so the launcher shows the manifest's declared display name instead of the SD folder name; apply_consent() partitions declared caps into immediately-granted and pending; jpp_app_consent_prompt() (public) wraps prompt_permission() + grant_persist() for lazy per-use prompts; grants persisted in /data/grants/<app_id>.json. Background discovery: discover_apps_background_start(normal_mode) spawns a one-shot FreeRTOS task that scans /sd/apps into a static buffer and sets a volatile ready flag; discover_apps_background_ready() polls that flag (non-blocking); discover_apps_apply_to_shell(shell) clears SD entries via jpp_ui_shell_clear_sd_apps() then re-adds the buffered results. The main loop triggers a fresh scan each time top_screen transitions to "launcher" (skipped on ui_tick == 0 to avoid a duplicate boot scan). Background runs: jpp_app_bg_launch(app_id, task) headless-launches an app (MP on_task(name) via jpp_mp_runner_run_task, native jpp_app_task_entry via jpp_native_loader_run_task); jpp_app_bg_running()/_finished()/_teardown() are polled by the main loop; consent prompts are denied while a bg run is active (CONSENT_HEADLESS_DENY); at every foreground exit sd_app_task_fn syncs the schedule via jpp_bg_scheduler_sync_app() (entries exist iff manifest background.enabled + persisted background.register grant). launch_app_from() (backing launch_sd_app()) sets s_sd_app_id before its first failure check and calls record_app_crash(reason) on every pre-launch failure path (manifest load, preflight, MP vm prepare/start, task creation) — not just runtime crashes — so jpp_app_crash_take() can surface a LAUNCH_FAILED dialog for launch failures too (see app_main). |
jpp_bg_scheduler |
module | main/jpp_bg_scheduler.c/.h |
background schedule table: init() loads /data/bg_schedule.json; sync_app() replaces an app's entries (preserving last_run by task name); due() checks interval (now >= last_run + interval_s) and cron (5-field, fires once per matching minute) against the DS1307 RTC; mark_run() is called before launch so a crashing task cannot re-fire in a loop. Limits in jpp_resource_budget.h: 8 entries total, 30 s run quota (overrun ⇒ BG_TASK_KILLED + esp_restart() — a task kill could leak broker locks/app pool), 60 s minimum interval. |
jpp_file_picker |
module | main/jpp_file_picker.c/.h |
firmware file browser shim over jpp_file_browser_core: lists via opendir/readdir (hidden files skipped), renders to the SSD1306, takes keys from s_action_queue; dirs have "/" suffix, ".." navigates up, long names scroll as marquees; SDK counterpart is jpp_sdk_file_pick() |
jpp_serial_mgr |
module | main/jpp_serial_mgr.c/.h |
JPPD-SMP v1 implementation: jpp_serial_mgr_init() installs the native USB-Serial-JTAG driver (usb_serial_jtag_driver_install) — not UART0, since this board's single USB-C port has no separate UART bridge chip and UART0 is unreachable from a host — and wraps the ESP_LOG vprintf sink with a TX mutex; smp_rx_task scans for the 4-byte SOF (\x01JPP), verifies CRC-16/CCITT-FALSE, dispatches to command handlers. jpp_serial_mgr_set_rtc(rtc) provides the RTC pointer for timestamp generation (call after jpp_serial_mgr_init()). jpp_serial_mgr_needs_render() / handle_action() / render() integrate with the main-loop UI cycle (consent dialog + active-session screen). Both consent dialogs (SESSION_START's and APPLY_BACKUP's) play JPP_BUZZER_SOUND_NOTIFY via jpp_buzzer_play_async() the moment they're shown, skipped on the provisioning auto-accept path since no dialog appears there. SD app launch is blocked while a session is open. On provisioning builds (CONFIG_JPP_LRV_PROVISIONING), the first SESSION_START after boot auto-accepts with no OLED dialog (s_first_session_done latch in handle_session_start()) so scripts/prepare_device.py can run unattended; every session after that requires manual consent same as production. See JPPD-SMP WIRE FORMAT section below for the protocol specification. |
jpp_lrv |
module | main/jpp_lrv.c/.h |
LRV AT24C32 EEPROM management and crypto: jpp_lrv_init(bus), jpp_lrv_has_data(), jpp_lrv_get_display_info(serial, pubkey_str[16]), jpp_lrv_get_full_data(), jpp_lrv_get_run_size(), jpp_lrv_build_challenge(), jpp_lrv_sign_challenge(), and jpp_lrv_store_identity() (provisioning builds only, CONFIG_JPP_LRV_PROVISIONING). One EEPROM region: a write-once IDENTITY region at 0x0000 holding the raw, unencrypted record behind an 8-byte header (magic "JLRV" + len + CRC-16). The magic marks the region as written (an unprovisioned chip fails the check); there is no version field and no format negotiation — this is the only layout the firmware knows. There is no password, no encryption, and no STATE/unlock-cache region — jpp_lrv_init reads the record straight into a RAM jpp_lrv_data_t, so a provisioned device is usable from boot and jpp_lrv_has_data() is the only gate every consumer checks. The identity survives factory reset and reflash (external chip). Record layout: serial(2) + device_pubkey(32) + device_seckey(64) + cert_sig(64) + hwid(24) + cert_len(2) + cert(N) (mirrored by serialise_record() in scripts/lrv_manufacturing.py — keep the two in sync); run_size, device_type, and mfr_pubkey live in the certificate text only, not separate fields. jpp_lrv_get_run_size(out) parses "run_size=N" out of the certificate text (used by onboarding's "unit NN/20" line). |
jpp_lrv_server |
module | main/jpp_lrv_server.c/.h |
jpp_lrv_server_start(rtc) / _stop() / _is_running() / _get_addr(); port 3000; runs on jpp_http_server_core (pool-backed, like WebDAV), serving the same page for every path on GET/HEAD: cert, cert_sig, device_pubkey, challenge ({username}|{iso8601}), resp_sig; "Open Certificate Page" is a direct <a href> to https://jppdevice.by.m4l3vi.ch/verify?serial=<n>&cert=<base64url>&certsig=<base64url>&challenge=<url-encoded>&resp=<base64url> (no server-side redirect) — cert/certsig carry the issued certificate bytes and its manufacturer signature so the page can verify from scratch with no lookup, and challenge travels as one url-encoded opaque string (not split into separate name/ts params) so the site never has to reassemble it from parts and risk drifting from the signed bytes. No custom challenge input. Checks jpp_fileserver_get_status() for WebDAV mutual exclusion — redundant now that both hold the app pool, but it names which server to switch off instead of reporting "pool busy". Already foreground-bound: the settings screen stops it on leaving the verify subscreen. |
-
The 5th keypad button is called OK, never CENTER or CTR. It was renamed throughout the codebase, docs, and SDK (
JPP_SDK_KEY_CENTER→JPP_SDK_KEY_OK,jpp_sdk_claim_center→jpp_sdk_claim_ok, the"CENTER"keypad band →"OK", etc.) after firmware v1.1 — write new code and prose exclusively against the OK-named forms; if you see "CENTER" or "CTR" describing this button anywhere outside a deprecation shim (comments, strings, variable names), it is stale terminology left over from before the rename — fix it on sight. Internal, non-SDK-visible identifiers (firmware-private functions/statics, the keypad band string, MP qstrs/dict entries besides the two below) were renamed with a clean break — nothing outside this repo can reference those by name, so there was nothing to preserve. The one SDK-visible piece —jpp_sdk_claim_ok(formerlyclaim_center) plus theJPP_SDK_KEY_OK*/JPP_SDK_OK_CLAIM_*constants it uses — kept the old names as deprecated aliases (same values,__attribute__((deprecated(...)))in C; a seconds_symtabentry /jppsdkmodule-dict entry pointing at the same function) precisely because those names are the kind an already-compiled.bin/.mpyor external source tree can reference, and a straight rename there would have silently broken them (UNRESOLVED_SYM/AttributeError) with nosdk_mincheck able to catch it.sdk_mindid still move: the newJPP_SDK_KEY_OK*names require level 3 (minted alongsidewrap_textand the MicroPython-parity bindings, since firmware exporting the old names only is exactly the "symbol not yet reachable" case those two cover); code still using the oldCENTER-named forms keeps its originalsdk_min: 2. Seedocs/sdk-changelog.md(level 3, "The 5th keypad button is OK, not CENTER") for the full symbol list. The one deliberately-untouched lookalike in all of this iscenter_uv, a generic per-band ADC-voltage-midpoint field present on every band (UP/DOWN/LEFT/ RIGHT/OK alike) — it describes "center of a voltage range," not the button, and renaming it would be wrong. -
components/jpp_core/is the implementation source of truth for reusable firmware components. -
main/hosts firmware-specific modules that drive hardware directly (SSD1306, settings screen). -
Use
idf.py set-target esp32c6,idf.py build, andidf.py flashfor native workflow. -
Prefer Docker-based commands for reproducible builds and environment parity.
-
Agents: do NOT hunt for a local ESP-IDF installation. There is no expectation that ESP-IDF is installed on the host, and you should not probe for one (searching for
idf.py,IDF_PATH,export.sh, a toolchain, etc.) or try to install it. Build through the Docker flow instead (docker compose run --rm build idf.py build, or thetools/docsimage) — theidf.pycommands above are what runs inside those containers. For ESP-IDF / library / API / CLI documentation, reach for external tools (Context7 or WebSearch) rather than assuming local SDK sources or docs are present on disk. -
Keep hardware-facing docs aligned with the repo's reference pin map and Wokwi topology.
-
Settings live under
/data/settings.jsonand should preserve schema migration and recovery behavior. -
Flash budget (4 MB, ample): the target board has 4 MB flash (
CONFIG_ESPTOOLPY_FLASHSIZE_4MB) — corrected from an earlier 2 MB assumption that did not match the physical chip on production units (seescripts/prepare_device.py, which now verifies each unit's actual flash size against the image before flashing rather than trusting that assumption again — a mixed batch cannot be ruled out, and the wrong partition table can corrupt or brick a smaller-flash unit).partitions.csvlays outnvs(24 KB) +phy_init(4 KB), afactoryapp partition of0x360000(3,538,944 B),data_fs/runtime_fsSPIFFS (256 KB each, restored to their pre-2MB-squeeze size), and a restoredcoredumppartition (64 KB) — the six partitions exactly fill the 4 MB flash, none left unpartitioned. Measured ondevelopright after the switch: image0x1c55b0= 1,856,944 B, leaving0x19aa50= 1,682,000 B (~1.6 MB, 47.5%) free infactory— up from the ~77 KB (4%) most recently recorded here under the 2 MB layout.-Os(CONFIG_COMPILER_OPTIMIZATION_SIZE) is no longer required to fit, but stays on: smaller/faster-flashing image, no real downside for a device with no debugger attached in the field. Coredump-to-flash itself (CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH) remains a separate, still-disabled menuconfig choice — the restoredcoredumppartition only reserves the space it would need if that's ever turned on. The TLS trust store stays the single largest discretionary flash item by choice, not by constraint:CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN(43 roots, 17.9 KB) is kept over_DEFAULT_FULL(~130 roots, 69 KB) for a smaller, more auditable trust store — FULL would now fit easily inside the new headroom, so switching is a pure product decision, not a size one. Note the TLS code (esp-tls + mbedTLSssl_*/x509_crt, ~47 KB) was already linked before HTTPS existed, pulled in byesp_http_client; only the CA blob was being garbage-collected for want of anesp_crt_bundle_attach()caller. The RAM budget is unaffected by any of this — it moves independently of flash size, still comes out of the same ~250 KB unified SRAM (seeJPP_APP_POOL_BYTESand the WiFi/heap note below), and remains the tighter of the two constraints going forward. -
All public jpp_core headers use
#pragma once(no#ifndefguards). -
Error return types: use
jpp_<module>_result_torjpp_<module>_status_tenums; neveresp_err_ton the jpp_core public API surface. -
Shared string utilities (
jpp_str_eq,jpp_str_nonempty,jpp_str_copy,jpp_str_name_valid,jpp_str_has_parent_segment) live injpp_string_util.h; do not duplicate them per-module. The same rule applies tomain/helpers: file slurps go throughjpp_file_util, single-key NVS access throughjpp_nvs_util, and screen headers throughjpp_draw_util. -
Named constants for all hardware defaults: battery in
jpp_battery_core.h, keypad injpp_keypad_core.h, OLED injpp_oled_core.h, RTC injpp_rtc_core.h, ADC resolution injpp_hw_config.h, buzzer/DS1307 injpp_hw_config.h. -
Use
NULLfor pointer comparisons, not0. -
App SDK
set_frame(lines, count)— no footer parameter. Calling it leaves fullscreen canvas mode (text frames are windowed-only). -
App SDK canvas is windowed (128×48, rows 0–47) unless
jpp_sdk_canvas_fullscreen(ctx, true)is set, which exposes the full 128×64 (rows 0–63). The keyboard/UI helpers andjpp_kbd_coreonly ever use the windowed 48-row region. The SDK contextcanvas[]is sized for 64 rows; the main-loop renderer (app_main.c) blits pages 0–7 in fullscreen and pages 2–7 (with frame text) otherwise. A system modal (jpp_sdk_dialog/confirm/list/input/file_pick) drops fullscreen while it draws and restores it automatically on return viajpp_sdk_modal_done, so fullscreen apps (e.g. the MTProto client, which repaints all 64 rows every frame) never have to re-enable it after a prompt. -
App SDK code modules (
jpp_sdk_module_load/_run/_unload, native apps only) page a second ELF (exportingjpp_module_entry(ctx, api)) from the app's own/sd/apps/<app_id>/into the pool tail — one at a time. The module runs in the host app's task with the host's capabilities (it is the app's own code), so it is ungated;apiis an app-defined function table the host hands it. The Games app (apps/games/) is the reference user: a small resident hub + one game module loaded on demand, so the catalog never has to fit in the pool at once. -
App SDK
jpp_sdk_file_pick(context, out_path, out_path_len, out_result)— requiresfiles.full; browses/sdfrom root, ".." to go up, "/" suffix on dirs, marquee for long names; firmware counterpart isjpp_file_picker()inmain/. -
Backup settings:
Settings > Backup settings— "Backup to SD card" writes/sd/backups/settings_YYYYMMDD_HHMMSS.json(NVS + settings.json); "Restore from file" invokesjpp_file_picker, parses backup JSON, restores NVS namespaces (jpp_time,jpp_power,jpp_webdav,jpp_sound,jpp_user,jpp_input) and settings.json, then restarts. LRV data is NOT backed up or restored — the identity lives on the external AT24C32 EEPROM (bound to the RTC module) and is provisioned once at manufacturing. -
User's name: persisted in NVS namespace
jpp_user, keyusername(string, maxJPP_SETTINGS_USERNAME_MAX= 64 chars). Loaded at boot inload_username()(called fromrun_main_loop()after NVS init). Edited viaSettings > User's nametext input, or set once during first-boot onboarding (jpp_onboarding_run(), optional — cancelling or submitting empty leaves it unset). Used as the subject in LRV challenges ({username}|{iso8601}), as thename=parameter in the verification URL, and exposed to the App SDK (ungated) as theusernamefield ofjpp_sdk_device_status(). MeetApp defaults its identity nickname to this value on first run instead of prompting (see BUILTIN APPS). -
First-boot onboarding:
jpp_onboarding_run(shell, settings_state, rtc)(main/jpp_onboarding.c), gated on NVSjpp_onboard/done(u8, same idiom as dummy mode) so it runs exactly once. Three blocking screens drawn directly to the SSD1306 (no shell/dialog-stack involvement — same idiom asjpp_keyboard_input()/the serial-manager consent screen): (1) welcome + "This is unit NN/20" (shown only ifjpp_lrv_has_data()andjpp_lrv_get_run_size()succeeds) + "Press OK to set username" (OK only — not skippable); (2) username viajpp_keyboard_input(), optional; (3) "Hello, {username}! / Connect to Wi-Fi now? / > Yes / No" — Yes pushes the"settings"screen onto the UI stack (Settings owns the actual Wi-Fi scan/connect UI) rather than duplicating it. Called fromrun_main_loop()right afterload_username(), once the keypad task/action queue exist. Screens (1) and (3) draw a status line on the last display row (draw_status_line()): live time fromrtcon the left (jpp_rtc_get_current(),--:--when unavailable), battery percentage flush right fromshell->status_battery_pct(blank when -1) — same%3d%%field as the launcher status bar, but right-aligned since onboarding draws no Wi-Fi/battery icons.wait_key()therefore blocks for at most 1 s and returnsJPP_UI_ACTION_NONEon timeout so the clock ticks while a screen sits idle. Onboarding blocks the main loop, sorun_main_loop()seedsshellstatus with one RTC hw read + onejpp_battery_read()(unders_kpad_ctx.adc_mutex) before calling it — that seed replaces the old post-onboarding "initial RTC read" block; the battery figure stays frozen for the flow's duration, which is why only the clock is refreshed live. -
Onboard LED: WS2812 addressable RGB, single pixel, GPIO8 (
JPP_HW_LED_GPIOinjpp_hw_config.h) — the only GPIO not claimed by the documented pin map. Driven viajpp_led_core(RMT TX, hand-rolled encoder, noled_stripdependency). Ungated SDK surface:jpp_sdk_led_set_color(ctx, r, g, b)/jpp_sdk_led_off(ctx). -
Dummy mode: persisted in NVS namespace
jpp_dummy, keysdummy_en(u8, 1=enabled) anddummy_app_id(str, SD app ID). Loaded at boot inload_dummy_mode()inapp_main.c. When enabled: all launcher navigation is blocked, the locked app is auto-launched on boot and re-pushed after each exit, and thedummy_modebool is set onjpp_sdk_context_tso apps can calljpp_sdk_is_dummy_mode(). Disabled by holding OK at boot — detected bycheck_ok_held_at_boot()(5 ADC samples, 3-of-5 in OK voltage range); on detection,settings_do_dummy_mode_save(false, NULL)clears NVS and a brief OSD confirms. Holding OK at boot also suppresses the startup jingle (s_boot_ok_heldflag), independent of dummy mode. Settings > Dummy Mode is only reachable when dummy mode is off (the section is inaccessible in dummy mode because all launcher input is swallowed). Enabled viasettings_do_dummy_mode_save(true, app_id)injpp_settings_screen.c; device restarts immediately after save. -
LRV: identity lives on the AT24C32 EEPROM (0x50, on the RTC breakout), loaded by
jpp_lrv_init()at boot — NOT in NVS. It is stored raw (unencrypted): there is no sticker password, no unlock screen, and no lock state. TheSettings > * Device Info *section appears whenjpp_lrv_has_data()is true (a valid IDENTITY record is on the EEPROM) and opens straight on the MAIN subscreen. The identity survives factory reset and full reflash (external chip) and factory reset does not touch it at all. Pressing OK in the MAIN subscreen logs the certificate, cert_sig, device_pubkey, challenge ({username}|{iso8601}), and resp_sig (separate WARN lines) and starts the HTTP verification server on port 3000 (mutually exclusive with WebDAV). Challenge format:{username}|{YYYY-MM-DDTHH:MM:SSZ}(username from NVSjpp_user/username; built byjpp_lrv_build_challenge()). Thehwidfield is the device's eFuse MAC (esp_efuse_mac_get_default()).run_size/device_typelive in the certificate text, not separate fields; the manufacturer public key is never stored on the device (verifiers hold it out-of-band). -
LRV provisioning is single-use, write-once, and not user-accessible. The EEPROM-write path (
jpp_lrv_store_identity()+ JPPD-SMPPROVISION_LRV0x30) is compiled in only whenCONFIG_JPP_LRV_PROVISIONING=y(seemain/Kconfig.projbuild); production firmware contains no identity-write code. A firmware write-once guard refuses to overwrite an already-provisioned IDENTITY region. Manufacturing flow (one command per unit): build both images once withscripts/build_images.sh(production →build/, provisioning →build-prov/via thesdkconfig.provfragment), then runscripts/prepare_device.py --config scripts/mfg.tomlfor each board. The orchestrator flashes the provisioning image, opens one JPPD-SMP session — auto-accepted by the device with no button press, since the provisioning image auto-allows the first session after boot (reads the device's own eFuse MAC ashwidviaGET_INFO— no manualesptool.py chip_id; syncs the device RTC to the host clock viaSET_TIME; write-oncePROVISION_LRV; then uploads every app to the SD card so they survive the reflash), flashes the production image, auto-increments the serial fromscripts/mfg.toml, and appends the unit (serial, hwid, pubkey, timestamp) toscripts/ledger.csv. No password is generated or printed — a provisioned unit needs no sticker. The lower-levelscripts/lrv_manufacturing.py provision-device …(explicit--serial/--hwid) still exists for one-off/manual provisioning; both share the record buildermake_identity_record(). -
Buzzer volume: persisted in NVS namespace
jpp_sound, keybuzzer_vol(u8). Discrete steps: 0 / 25 / 50 / 75 / 100. Implemented viagpio_set_drive_capability()(CAP_0–3 maps to 25–100%); duty stays fixed at 50% so tone quality is constant across levels. 0% mutes by zeroing LEDC duty. Loaded and applied before the startup chime (load_buzzer_volume()inapp_main.c, afternvs_flash_init). Changed at runtime viasettings_do_volume_change(). Settings > Sound: LEFT/RIGHT on Volume cycles level (plays CLICK at new level); LEFT/RIGHT on Jingle cycles startup jingle and plays a preview; OK on Test plays the selected startup jingle. -
Startup jingle: persisted in NVS namespace
jpp_sound, keystartup_jingle(u8,jpp_startup_jingle_t). Loaded alongsidebuzzer_volinload_buzzer_volume(). Changed at runtime viasettings_do_jingle_change(). The boot startup sound inapp_main.ccallsjpp_buzzer_play_startup_jingle_async(s_startup_jingle)(async, so the launcher comes up while it plays) instead of the fixedJPP_BUZZER_SOUND_STARTUP. Settings > Sound jingle previews (LEFT/RIGHT cycle, OK on Test) also use the async form, so the UI never blocks for the jingle and a new selection cuts the previous preview off. Jingles: DEFAULT (original chime), WinXP Startup, Win3.1 Startup, Mac128k, Rick Roll, Nokia Power On, Nokia Tune, Sandstorm, DOOM, Clutterfunk, OFF. The non-default melodies are RTTTL transcriptions (kept faithful to the source d/o/b headers; repeated-note jingles like Sandstorm carve a small rest out of each note so the stutter re-attacks). -
Back button gesture: persisted in NVS namespace
jpp_input, keyback_gesture(u8,jpp_keypad_back_gesture_t: 0 = Hold [default], 1 = Double-click). Loaded at boot viaload_back_gesture()inapp_main.c(alongsideload_buzzer_volume()) into the file-scopes_back_gesture_mode, which lives next tokeypad_taskbecause that is its only consumer. Changed at runtime viasettings_do_back_gesture_change()inSettings > Controls(one row, LEFT/RIGHT toggles) — effective on the next 20 ms poll, no restart. In Hold mode, behaviour matches what existed before the setting: holding OK ≥JPP_KEYPAD_DEFAULT_LONG_PRESS_MS(700 ms) fires Back (with auto-repeat while held), a short release fires OK instantly. In Double-click mode, a short OK release defers OK untilJPP_KEYPAD_DEFAULT_DOUBLE_CLICK_MS(300 ms) passes with no second click; a second click inside that window fires Back instead, with no OK for either. A hold is still detected in Double-click mode, it simply isn't Back — apps that claimed it still receive it. Seejpp_keypad_coreand the OK gesture policy convention below. -
The WebDAV and LRV servers are foreground activities, not background services. Both run on
jpp_http_server_core, which acquiresjpp_app_poolat start and releases it at stop, so a server and an SD app can never be resident at once and the memory is only committed while a server is actually on screen. Two rules follow. (1) Every path that leaves a server's screen must stop it: BACK on the WebDAV screen (jpp_ui_core.c), leaving the LRV verify subscreen (jpp_settings_screen.c). Adding a new way out of either screen without a stop leaks the whole 80 KB pool and leaves a task serving invisibly. (2)jpp_http_server_stop()must complete before the pool is handed to anything else — it blocks until the server task has genuinely exited, because that task's stack is pool memory; on theSTOP_TIMEOUTpath it deliberately keeps the pool held and leaves the server marked running rather than freeing memory out from under a live task. Do not "fix" that by releasing anyway. Deep sleep is held off while either server runs (app_main.c), since the screen showing the address is the whole point. -
OK gesture policy lives in exactly one place:
keypad_task()inmain/app_main.c. It is the only code that can see both the user's Back preference (s_back_gesture_mode) and what the foreground app has claimed (jpp_sdk_context_t.ok_claim), so it is where rawOK_LONG/OK_DOUBLEevents become a Back action, a raw app key, or nothing (keypad_handle_ok_gesture()). Do not push this decision back down intojpp_keypad_core(it is a detector) or intojpp_ui_normalize_action()(it sees one event and no context, and deliberately does not map OK gestures). The hold auto-repeat feeds only the UI action queue, never an app — it exists so Back can pop several screens, not to re-fire an app's gesture. Apps never read the user preference: seejpp_sdk_claim_okunderjpp_sdk_bridge. -
SDK-visible types are append-only. Native apps are separately-built ELF32 binaries loaded from
/sd/apps/…against whateverjpp_sdk_bridge.hlooked like when they were compiled, and they readjpp_sdk_context_tfields directly (apps/demoscene/src/demoscene.candapps/games/src/games_gfx.cboth readcanvas_fullscreen). Adding a field anywhere but the tail of that struct — or renumberingjpp_sdk_key_event_t/ any other enum an app can see — silently shifts the offsets an already-deployed.binwas built against, with no load-time error. Append new fields at the end of the struct and new enumerators at the end of the enum; aliasing an existing value (asJPP_SDK_KEY_BACKdoes forJPP_SDK_KEY_OK_LONG) is free. Adding a newjpp_sdk_*function is safe but needs a matchings_symtabentry injpp_native_symtab.cor apps calling it die at launch withUNRESOLVED_SYM. This applies tojpp_sdk_native_services_ttoo, and more sharply: that struct is embedded by value near the top ofjpp_sdk_context_t, so growing it even at its own tail shifts every field after it — which is exactly what the first SDK v2 commit did by insertingnet_connectmid-struct. It is now frozen at its v1 shape; new service callbacks go injpp_sdk_services_v2_t, which sits at the tail ofjpp_sdk_context_tand is installed byjpp_sdk_set_services_v2()after bind.tests/test_sdk_abi.pypins both field lists, so a violation fails the host tests instead of silently corrupting deployed apps; it also cross-checks the C capability whitelist andJPP_SDK_VERSIONagainst the Python mirror, afternetwork.connectshipped intests/validate_manifests.pywithout ever being added tojpp_manifest_v2_is_allowed_capability(). -
Custom dim clock lines:
/sd/clocklines.txt— one line per entry (max 64 entries, 2 KB file limit). If the first line is!r, stock lines are replaced; otherwise custom lines are appended to the built-in pool. The file is re-read on boot and on every return to the launcher. Implementation:load_clocklines()/pick_random_line()inapp_main.c. -
Firmware version string:
JPPDOS_VERSIONinmain/jpp_settings_screen.h. -
BLE messages > 512 bytes: a single GATT characteristic value is hard-capped at 512 bytes (
BLE_ATT_ATTR_MAX_LEN, a BLE spec limit, not tunable), in both directions —ble_read_char/ble_write_chardo long read/write up to that but no further. To send a larger payload, use the shared app-side helperapps/common/jpp_ble_msg.{c,h}:jpp_ble_msg_send()frames the data into ordered chunks (BEGIN[total_len,crc32]+DATA[seq,payload≤400]) overble_write_char, andjpp_ble_msg_host_recv()reassembles them on the peer fromble_host_wait_write, verifying the CRC. Compiled into each app that uses it (addapps/common/jpp_ble_msg.c+-Iapps/commonto the app'sbuild_shared.pyand CMakeLists — see MeetApp). MeetApp's round-1.5 is the reference user. One transfer per connection; flow control rides the SDK's acknowledged writes. -
The native and MicroPython SDKs expose the same surface — keep it that way.
components/jpp_core/src/jpp_mp_sdk_module.cis thejppsdkMicroPython module and must mirror everyjpp_sdk_*call injpp_sdk_bridge.h. Exactly two deliberate exceptions exist, both structural rather than "not got round to it":jpp_sdk_module_load/_run/_unload(they page in a second ELF32 binary — meaningless for a MicroPython app, which hasimportinstead, and the header already returnsINVALID_STATEfor MP apps) andjpp_sdk_push_key(a firmware-internal injection hook called only bykeypad_taskinmain/app_main.c, not app-facing in either language). Anything else missing from the Python side is a defect, not a design choice — sixteen such gaps were closed at once (seedocs/sdk-changelog.mdlevel 3). Adding a newjpp_sdk_*function therefore means three registrations, not one, and skipping any of them fails differently: thes_symtabentry injpp_native_symtab.c(miss it and native apps die at launch withUNRESOLVED_SYM), the binding +jppsdk_module_globals_tableentry injpp_mp_sdk_module.c, and aQ(name)line incomponents/micropython/qstrdefsport.h. That last one is the easy one to miss:jpp_mp_sdk_module.cis compiled outside themicropythoncomponent and so is never scanned bymakeqstrdefs.py, meaning everyMP_QSTR_xxxit uses — function names, keyword-argument names, and dict keys alike — must be declared there by hand. The failure is a compile error ('MP_QSTR_foo' undeclared here (not in a function)), so it cannot ship silently, but it will stop a build cold. Binary payloads usemp_get_buffer_raise/mp_obj_new_bytes; size-varying output buffers come from the MicroPython GC heap viam_new/m_del(which lives in the app pool, where app-owned data belongs) rather than new static.bss, since.bssis the scarcer budget — see the flash/RAM note below. -
SDK versioning: the native bridge exports a single API level
JPP_SDK_VERSION(injpp_manifest_core.h, currently3). A manifest declares the minimum level it needs viasdk_min(integer ≥ 1);jpp_manifest_v2_validate()rejects an app whosesdk_minexceedsJPP_SDK_VERSIONwithJPP_MANIFEST_SDK_TOO_OLD(surfaced through theLAUNCH_FAILED/MANIFEST_REJECTEDpath).sdk_minis a floor only — there is nosdk_max(removed): the SDK surface only grows in a backward-compatible way, so an app built for an older level keeps running on newer firmware. BumpJPP_SDK_VERSIONwhenever a backward-compatible symbol/capability is added — but only once per released level. Level 1 shipped in firmware v1.0-RTM and level 2 in v1.1, so both are closed. Level 3 is currently open (minted byjpp_sdk_wrap_text, and now also carrying the MicroPython-parity bindings, unreleased): further additions made before the next release belong in 3, exactly as level 2 absorbed four separate additions while unreleased. Folding a fifth addition into 2 now would leavesdk_min: 2meaning two different surfaces in the field.Choosing the number (contribution policy). Set
JPP_SDK_VERSIONto (last released level) + 1 — not (master + 1), and not the next unused integer. A level is closed by a release, not by being merged, so every branch in flight deliberately targets the same number and they converge by construction: two branches that both bump2→3merge cleanly to3(base 2, both sides 3), which is the intended outcome, not a collision to avoid. Keep the per-level entries indocs/sdk-changelog.mdand thesdk_minrow indocs/manifest.mdlist-shaped (one bullet / one·-separated clause per addition) so parallel branches conflict in a way that resolves by keeping both, rather than one silently replacing the other's description. If a release is cut while your branch is open, you must re-target the level by hand — git cannot catch this, because both sides agree on the number, so the merge succeeds silently and your addition lands inside an already-shipped level, reintroducing theUNRESOLVED_SYMclass of bug with no up-front rejection. This is not hypothetical: the CENTER-claim API (now the OK-claim API — see the CONVENTIONS rename note above) landed on a parallel branch without bumping the constant at all, which is why level 2 contains four additions rather than three. Keep the Python mirrortests/validate_manifests.py(SDK_VERSIONandALLOWED_CAPABILITIES) in sync with the C constant andjpp_manifest_v2_is_allowed_capability(), same as the other manifest rules —tests/test_sdk_abi.pyenforces both. Every level bump also gets a section indocs/sdk-changelog.md(the per-level record of what was added, and the pagesdk_minguidance points at) plus a row in thesdk_mintable indocs/manifest.md— same commit, per the documentation-maintenance rule. Note what level 2 actually contains:network.connect,https.request, the crypto primitives, and the CENTER-claim API as it shipped then (jpp_sdk_claim_center,JPP_SDK_KEY_BACK/_CENTER_HOLD/_CENTER_DOUBLE— renamed to the OK-named forms at level 3, old names kept as deprecated aliases), which landed on a parallel branch without bumping the constant;jpp_sdk_confirmalso only became resolvable at level 2, having been declared but omitted froms_symtabat v1.0-RTM.jpp_sdk_wrap_textwas the same defect and is what mints level 3 — declared and documented since v1.0-RTM but absent froms_symtab, so every native app calling it died withUNRESOLVED_SYM. Adding a symbol tos_symtabis an SDK-surface change even when the function itself is untouched: what changed is its reachability from a loaded app binary, andsdk_minis the only way an app can require it. The same reasoning applies to adding a MicroPython binding for a function that already existed in C — the surface a Python app can reach is what changed — which is why the sixteen parity bindings (see the MicroPython SDK parity rule below) also land in level 3 rather than being treated as a no-op. -
Capability consent is lazy / per-use:
apply_consent()inmain/jpp_app_dispatch.conly grants caps at launch for already-persisted tier-1; all other declared caps go into the pending set (jpp_sdk_set_pending_caps()). The prompt fires the first time an SDK call requires that cap viajpp_sdk_ensure_cap()→consent_prompt_cb→jpp_app_consent_prompt()→prompt_permission(). Tier rules: ungated surface (scoped/shared storage, KV helper, IPC, device status, get_time, frame/canvas/keys/buzzer/LED/wakelock/dialog helpers) needs no declaration and no consent — scoping is enforced by path construction; tier-1 (http.request,https.request,ble.scan,ble.advertise,background.register,esp_now) prompts once then persists to/data/grants/<app_id>.json; tier-2 (files.full,network.bind,network.connect,ble.connect,ble.host) prompts on first use every launch, never persisted. Two caps add a second, resource-scoped prompt on top of the cap prompt:files.fullper path (path_prompt, never persisted) andhttps.requestper origin (origin_prompt→jpp_app_origin_prompt(), persisted one-per-line to/data/grants/<app_id>.origins— a separate file becausegrant_persist()rebuilds the.jsonfrom the fixedTIER1_CAPSarray and would drop anything else stored there). During headless background runsjpp_app_consent_prompt()denies everything (CONSENT_HEADLESS_DENY) — only launch-time persisted tier-1 grants are usable. A denied cap remains out of the broker caller list — the broker enforces the gate, andjpp_sdk_ensure_cap()logs every denial (user-declined, not-declared-in-manifest, or grant-overflow) under tagjpp_sdk.prompt_permission()builds the request lines and delegates tojpp_sdk_confirm(), which renders viajpp_sdk_set_frame+jpp_sdk_wait_key(runs from the app task; must NOT use SSD1306 directly ors_action_queue). An-Wunused-functionwarning onprompt_permissionindicatesjpp_app_consent_prompt()has been broken. NOTE: a manifest may declare up toSD_MANIFEST_CAP_MAX(16) capabilities, which must stay<= JPP_SDK_PENDING_CAP_MAX— a smaller limit silently truncates the manifest list and drops the trailing caps so they can never be granted. Whenjpp_sdk_ensure_cap()grants a pending cap it appends to the caller list pointing at the stable pending-slot string and leaves the pending entry in place (the caller-list check short-circuits future lookups); it must never compact the pending array in a way that mutates a buffer the caller list points at.
- Do not bypass the service broker for file, network, keypad, RTC, or storage access.
- Do not treat Wokwi success as hardware sign-off for ADC, RTC, power-loss, Wi-Fi, or SD reliability.
- Do not invent a second architecture path outside the ESP-IDF native core.
- Do not add package-manager or monorepo guidance; this repo is firmware-first.
- Do not put firmware-layer code (SSD1306 direct calls, settings screen) in
jpp_core/— it belongs inmain/. - Do not make
apply_consent()auto-grant capabilities without user confirmation. The interactiveprompt_permission()dialog is intentional security UX — replacing it with unconditional grants silently removes the permission model and breaks the broker's trust boundary. Any refactor ofapply_consent()must keep the tier-1/tier-2 rules intact (see CONVENTIONS above).
| App ID | Name | Notes |
|---|---|---|
settings |
Settings | Full settings screen; Shutdown/Reboot, Wi-Fi, Time, Sleep timers, Sound (buzzer volume 0/25/50/75/100% + startup test), Controls (Back button gesture: Hold / Double-click), SD card, Backup settings, Factory Reset, * Device Info * (LRV only), User's name, Dummy Mode (single-app lock; hold OK at boot to disable), About |
webdav |
WebDAV server | WebDAV file transfer screen; password settings submenu (random or static); dim clock suppressed while server is running. Foreground activity: the server holds the app pool while up, so leaving the screen (BACK) stops it — it never serves in the background |
| SD apps | (discovered) | /sd/apps/<id>/manifest.json — MicroPython or native C binary |
testapp_native / testapp_mp |
SDK Test (C) / (MP) | The two App SDK test apps — menu-driven exercises of every SDK capability, in C and MicroPython. Built in-tree by idf.py build (apps/testapp_native/, apps/testapp_mp/); testapp_mp needs mpy-cross 1.28.0 on PATH. They both declare the full capability set, so launching them also exercises the consent-prompt flow. The reference for SDK behaviour: change jpp_sdk_bridge and you update these in the same commit — that is why they live here rather than in jppdos-apps, so a surface change breaks their build immediately instead of leaving a stale showcase in another repo |
games |
Games | Native hub app + 9 dynamically loaded game modules (<name>.mod.bin, loaded one at a time via jpp_sdk_module_load): Tetris (90°-rotated UI by default, toggle in Settings), Pong (single-player vs CPU + BLE two-device multiplayer with host-authoritative physics + paddle anti-cheat), Snake, Breakout, 2048, Flappy, Racer, Connect-4 (BLE), Battleship (BLE, SHA-512 board commit/reveal). Fullscreen 128×64 canvas; per-game high scores in the hub's KV (/sd/apps/games/.kv.json, keys tetris_hs/pong_sp_hs/pong_mp_hs/snake_hs/breakout_hs/g2048_hs/g2048_tile/flappy_hs/racer_hs/c4_mp_wins/bship_mp_wins/tetris_rot/sound_on); discovery via manufacturer AD (company 0x4A50, magic 'G', game id). Caps: ble.scan/ble.advertise/ble.connect/ble.host (only prompted when entering a multiplayer game). apps/games/ |
demoscene |
DemoScene | Native oldskool megademo: 8 auto-cycling scenes (title card, 3D starfield, plasma, rotozoomer, tunnel, wireframe cube, doom fire, credits with raster bars) on the fullscreen 128×64 canvas, plus a sine-wave scrolltext overlay and a looping square-wave chiptune (async buzzer, one bar re-armed at a time). All integer math — 256-step sine LUT + 4×4 Bayer ordered dithering; plasma/tunnel/fire render half-res 2×2 blocks. No capabilities (canvas/buzzer/keys/wakelock are ungated). LEFT/RIGHT change scene, UP toggles music, DOWN toggles auto-advance, long OK exits. apps/demoscene/ |
mtproto |
MTProto (skeleton) | Not in this repo — lives in the sibling jppdos-apps repo, since it is a standalone app rather than an SDK showcase. Listed here only because it is the reference user of the SDK v2 surface: abridged TCP transport over jpp_sdk_net_connect (cap network.connect), the PQ/Diffie-Hellman auth-key handshake via jpp_crypto_rsa_encrypt/sha1/aes256_ige_*/dh_compute, and MTProto 2.0 encrypted message framing (SHA-256 msg_key + AES-256-IGE). Loadable footprint ≈ 11 KB of the app pool (≈3.7 KB code + ≈5.5 KB static buffers), which is the measurement that sized the crypto primitives out of the app pool. |
meetapp |
MeetApp | Native BLE contact-exchange app: Ed25519 identity (meetapp_identity.c, identity.bin in scoped storage), BLE scan/advertise/connect/host exchange (meetapp_ble.c), and a proof/verification module (meetapp_proof.c). The leader may add an optional free-text comment (max MEETAPP_COMMENT_MAX = 100 chars, prompted after confirming the peer set) that is rendered as a "Comment:" line at the top of the proof document; because it is part of the MuSig2-signed message, it is forwarded to every peer in the round-1.5 payload (alongside the nicknames + timestamp) so each rebuilt proof hashes to what was signed. Identity nickname defaults to the device's Settings > User's name on first run (jpp_sdk_device_status()'s username field) instead of prompting, truncated to MEETAPP_NICKNAME_MAX (16 chars); falls back to the nickname prompt only if no device username is set. "Reset identity" always prompts, for a nickname different from the device username. Caps: ble.scan/ble.advertise/ble.connect/ble.host. apps/meetapp/ |
- Native code should stay organized around ESP-IDF components and app/main entrypoints.
- Host-side checks live under
tests/; scenario fixtures undertests/fixtures/. - Keep docs concise and factual; prefer repo-backed commands over aspirational tooling.
idf.py set-target esp32c6
idf.py build
idf.py flash
docker compose run --rm build idf.py build
scripts/flash.sh
python3 -m pytest tests # host-side checks (docs, contract, manifests)
# --- Single-app build toolchain — MOVED to the sibling jppdos-apps repo ---
# (run these from a jppdos-apps checkout, not from here)
toolchain/build-image.sh # build the SDK image against the tip of this repo's master (JPPDOS_REF=<ref> to pick another); clones the firmware itself, no submodule
docker run --rm -v "$PWD:/app" jppd-app-sdk # build the app in the current dir → ./dist/<app_id>/
./deploy.py # pick apps + serial port in a TUI, upload over JPPD-SMP
# --- Docs site (tools/docs/) — MkDocs + shadcn theme, renders docs/ in place ---
docker build -f tools/docs/Dockerfile -t jppd-docs . # build the docs image (from repo root)
docker run --rm -p 8000:8000 -v "$PWD:/project" jppd-docs # live preview → http://localhost:8000
docker run --rm -v "$PWD:/project" jppd-docs build # static site → ./site/
pip install -r tools/docs/requirements.txt && mkdocs serve # host preview (no Docker)
python3 scripts/jppd_upload.py <port> <app_id> # upload build/apps/<app_id>/ to device via JPPD-SMP
python3 scripts/jppd_upload.py <port> <app_id> <dir> # upload from an explicit build directory
scripts/jpp_deploy.sh <port> # esptool-flash firmware only
scripts/jpp_deploy.sh <port> <app_id> [app_id ...] # flash firmware, then upload one or more built apps via JPPD-SMP
scripts/jpp_deploy.sh <port> --all # flash firmware, then upload every app under build/apps/
# --- Manufacturing: prepare a fresh unit end-to-end (LRV + apps) ---
scripts/build_images.sh # build BOTH images once: build/ (prod) + build-prov/ (provisioning)
scripts/lrv_manufacturing.py keygen # one-time: generate mfr_pubkey.bin / mfr_seckey.bin
cp scripts/mfg.example.toml scripts/mfg.toml # edit run_size/device_type/mfr_seckey once per run
scripts/prepare_device.py --config scripts/mfg.toml # per unit: flash prov → provision LRV + upload apps (1 session) → flash prod → ledger
scripts/prepare_device.py --config scripts/mfg.toml --dry-run # read-only rehearsal: no flash/provision/upload (still shows hwid)App artefacts after idf.py build — copy directory contents to /sd/apps/<id>/:
build/apps/demoscene/ demoscene.bin + manifest.json
build/apps/meetapp/ meetapp.bin + manifest.json
build/apps/games/ games.bin + <name>.mod.bin (×9) + manifest.json
build/apps/testapp_native/ testapp_native.bin + manifest.json
build/apps/testapp_mp/ main.mpy + manifest.json
testapp_mp_bin is the only build step in this repo that needs mpy-cross
1.28.0, and it hard-fails idf.py build when absent (build_mp.py exits 1,
failing the custom target) rather than skipping the app. The project Dockerfile
builds mpy-cross 1.28.0 into /usr/local/bin, so the documented
docker compose run --rm build idf.py build flow already has it; only a
host-side build outside the container needs its own mpy-cross. pip install mpy-cross==1.28.0 does not work — that release is not on PyPI (it publishes 1.27.0.post2 then 1.28.0rc0.post2, nothing in between); the rc emits the same bytecode ABI (mpy v6.3) and is the one-line option, otherwise build v1.28.0 from source as the Dockerfile does. .github/workflows/release.yml builds it from source, reading the tag out of the Dockerfile so the two cannot drift. The
mtproto skeleton is not built here: it lives in the sibling jppdos-apps
repo and is built with jppd-build.
Native .bin files are ELF32 shared objects; the .bin extension is what the firmware expects.
The Games app (apps/games/) builds the hub games.bin plus one <name>.mod.bin per game; copy the whole build/apps/games/ directory to /sd/apps/games/ (the modules are the app's own data files, loaded on demand by the hub via jpp_sdk_module_load).
Adding a new app component under apps/ requires idf.py reconfigure (or a clean build) before idf.py build picks it up — ESP-IDF caches the component list.
J++Device Serial Management Protocol v1 — binary protocol over the native USB-Serial-JTAG peripheral (this board's single USB-C port has no separate UART bridge chip, so UART0 is not reachable from a host), coexisting with ESP_LOG text output on the same physical channel. A TX mutex (registered via esp_log_set_vprintf) prevents log bytes from interleaving with binary frame bytes.
Frame envelope (both directions):
[SOF: 4 B] 0x01 0x4A 0x50 0x50 ("\x01JPP")
[LEN: 2 B LE] byte count of PAYLOAD only
[PAYLOAD: LEN B]
[CRC: 2 B LE] CRC-16/CCITT-FALSE (poly=0x1021, init=0xFFFF) over LEN(2)+PAYLOAD(LEN)
Command payload (host→device): [SEQ:1][CMD:1][FLAGS:1][BODY…]
Response payload (device→host): [SEQ:1][STATUS:1][BODY…]
FLAGS is reserved (must be 0x00). SEQ is echoed in the response for request/response matching. File operations are restricted to paths starting with /sd.
Commands (cmd byte → handler):
| CMD | Name | Body (host→device) | OK body (device→host) |
|---|---|---|---|
| 0x00 | SESSION_START | [proto_ver:1] |
[proto_ver:1] |
| 0x01 | SESSION_END | — | — |
| 0x02 | GET_INFO | — | [fw_version: NUL-term][username: NUL-term][hwid: NUL-term "AA:BB:CC:DD:EE:FF"][sd_total:8 LE u64][sd_used:8 LE u64][sd_free:8 LE u64][sd_label: NUL-term] |
| 0x03 | GET_LRV_DATA | — | [cert: NUL-term][cert_sig: 64B][device_pubkey: 32B][challenge: NUL-term][resp_sig: 64B] |
| 0x04 | SET_TIME | [year:2 LE u16][month:1][day:1][weekday:1][hour:1][minute:1][second:1] |
— |
| 0x05 | KEEPALIVE | — | — |
| 0x10 | FS_LIST_DIR | [path: NUL-terminated] |
[count:2 LE] then N×[flags:1][size_or_count:4 LE u32][name: NUL-terminated] — files: byte size; dirs: first-level child count |
| 0x11 | FS_MKDIR | [path: NUL-terminated] |
— |
| 0x12 | FS_REMOVE | [path: NUL-terminated] |
— |
| 0x13 | FS_RENAME | [src: NUL-terminated][dst: NUL-terminated] |
— |
| 0x14 | FS_UPLOAD_BEGIN | [file_size:4 LE][path: NUL-terminated] |
[xfer_id:1] |
| 0x15 | FS_UPLOAD_CHUNK | [xfer_id:1][chunk_idx:2 LE][data…] |
[chunk_idx:2 LE] |
| 0x16 | FS_UPLOAD_END | [xfer_id:1][crc32:4 LE] |
— |
| 0x17 | FS_DOWNLOAD_BEGIN | [path: NUL-terminated] |
[xfer_id:1][file_size:4 LE][chunk_count:2 LE][crc32:4 LE] |
| 0x18 | FS_DOWNLOAD_CHUNK | [xfer_id:1][chunk_idx:2 LE] |
[xfer_id:1][chunk_idx:2 LE][data…] |
| 0x19 | FS_DOWNLOAD_END | [xfer_id:1] |
— |
| 0x1A | APPLY_BACKUP | [path: NUL-terminated] (SD path to backup JSON) |
— (device restarts on Allow; ERR_DENIED on Deny) |
| 0x30 | PROVISION_LRV | [record: raw LRV identity record] (provisioning builds only) |
— (ERR_EXISTS if already provisioned; ERR_INVALID on bad record) |
PROVISION_LRV (0x30) exists only in firmware built with CONFIG_JPP_LRV_PROVISIONING=y; production firmware answers ERR_INVALID (unknown command). It write-once-writes the raw record to the AT24C32 EEPROM IDENTITY region and refuses (ERR_EXISTS) if an identity is already present. APPLY_BACKUP no longer carries LRV data. Sent by scripts/lrv_manufacturing.py provision-device.
GET_LRV_DATA requires an LRV identity to be present (jpp_lrv_has_data()); returns ERR_NOT_FOUND otherwise. Challenge string format: {username}|{YYYY-MM-DDTHH:MM:SSZ} using the device RTC at time of request. resp_sig is the Ed25519 signature over the challenge bytes (64 B raw). Response buffer is a static 512-byte array (s_lrv_resp_buf) in jpp_serial_mgr.c.
SET_TIME validates the fields with jpp_rtc_datetime_valid() (ERR_INVALID on failure or a body that isn't exactly 8 bytes), applies them to the in-RAM RTC state via jpp_rtc_set_time(), and additionally writes them to the DS1307 via jpp_rtc_hw_write() when hw_attached is true — mirrors the existing ntp_apply() pattern in app_main.c.
KEEPALIVE (0x05) is a pure no-op — handle_keepalive() in jpp_serial_mgr.c just replies OK. It exists only so a host with nothing else to send (idling on user input, redrawing a UI) can still reset the inactivity timer, which dispatch_command() already does for every valid, session-gated command before invoking its handler; KEEPALIVE adds no logic of its own and is gated the same as any other command (ERR_NO_SESSION outside an open session).
FS_LIST_DIR flags byte: bit 0 = 1 if directory, 0 if file; bits 1–7 reserved.
FS_UPLOAD_END / FS_DOWNLOAD_BEGIN CRC32 field: CRC-32/ISO-HDLC (poly 0xEDB88320 reflected, init/xorout 0xFFFFFFFF), matching Python's zlib.crc32. Implemented by crc32_buf() in jpp_serial_mgr.c — a self-contained bitwise routine, not esp_rom_crc32_le(), whose ESP32-C6 ROM convention does not match zlib.
Chunk size constant: SMP_CHUNK_SIZE = 1024 B. Max one active transfer at a time; xfer_id is always 0x00 in v1.
Status codes: 0x00 OK · 0x01 ERR_DENIED · 0x02 ERR_NOT_FOUND · 0x03 ERR_IO · 0x04 ERR_EXISTS · 0x05 ERR_INVALID · 0x06 ERR_BUSY · 0x07 ERR_NO_SESSION · 0x08 ERR_TRANSFER · 0x09 ERR_OVERFLOW · 0x0A ERR_APP_RUNNING
Session rules: SESSION_START triggers an OLED consent dialog; host blocks until user responds (Allow/Deny). An app must not be running (s_active_sdk_context == NULL). Session times out after SMP_SESSION_TIMEOUT_MS (30 s) of inactivity — any valid command resets this, including the no-op KEEPALIVE, for a host that would otherwise idle past the limit with nothing else to send. Deep sleep is suppressed while the dialog or an active session is displayed. SD app launch is blocked while a session is open. Holding OK while a session is open also ends it locally (same as SESSION_END) and fires a SESSION_ENDED event so the host learns immediately rather than via the next command's ERR_NO_SESSION or a 30 s wait.
Device-initiated events (device→host, unsolicited — not a reply to any command): [SEQ:1 = 0xFF][EVENT:1][BODY…]. SEQ = 0xFF is the sole discriminator between an event and a response; a host must reserve it and never assign it to an outgoing command's own SEQ (scripts/jppd_upload.py's _next_seq() skips it), and must check for it before interpreting the second byte as a STATUS code. Currently one event: 0x01 SESSION_ENDED (empty body) — sent by send_event() in jpp_serial_mgr.c right before close_session() when the user holds OK. Built on the same send_response() framing/CRC code as an ordinary reply, just with SEQ fixed at SMP_SEQ_EVENT instead of an echoed command SEQ.
- The hardware profile remains ESP32-C6 Super Mini class with OLED, RTC, SD, keypad, passive piezo buzzer, and an onboard WS2812 addressable RGB LED (GPIO8) support.
- DS1307 RTC is I²C-attached (0x68); CH-bit quirk handled in
jpp_rtc_hw_read(). The RTC is optional —jpp_rtc_state_init()probes the bus and only attaches when the chip responds, so the firmware boots and runs normally with no DS1307 fitted; without it (and before any NTP sync) the time is unavailable and all clocks render--:--. - AT24C32 EEPROM is I²C-attached (0x50) on the DS1307 breakout board; also optional (
jpp_eeprom_state_init()probes first). It stores the LRV identity (seejpp_lrv), chosen because the external chip survives factory reset and firmware reflash and binds the identity to the RTC module. ConstantsJPP_HW_EEPROM_I2C_ADDR/JPP_HW_EEPROM_SIZE_BYTESinjpp_hw_config.h. - Buzzer: GPIO3 LP pad, LEDC PWM. Drive strength is varied at runtime for volume control (CAP_0–3 = 25–100%); CAP_3 is the boot default and the 100% level. Do not hard-code
GPIO_DRIVE_CAP_3after init — usejpp_buzzer_set_volume()instead. - Screen standby and sleep durations are configurable in the Settings > Sleep timers section.
- The current in-repo simulation board is
board-esp32-c6-devkitc-1; Wokwi stays a reference only. - Preserve error codes and troubleshooting markers in lower-level docs and runtime logs.
- WiFi/heap coexistence: the C6 has a single unified SRAM (no PSRAM). WiFi management/data frames and lwIP pbufs are allocated from the general heap, and TX management frames have no static-buffer config knob. ESP-NOW (
jpp_espnow_native) is a fourth consumer of the same STA-mode WiFi driver alongsidehttp.request, WebDAV, and the LRV server; it doesn't touch the BLE-suspend gating described below (that gate is keyed on the HTTP servers, not on WiFi use in general). Under heavy networking (a WebDAV transfer) low free heap makes those allocations fail (wifi:m f ...), silently wedging the radio until the load stops. Mitigations in place: the HTTP servers no longer allocate from the heap at all —jpp_http_server_coretakes its task stack and its 32 KB I/O buffer fromjpp_app_pool, which is whyesp_http_serverwas replaced rather than tuned (httpd_start()mallocs its stack, socket table and scratch, andhttpd_config_t.task_capsonly chooses which heap);esp_wifi_set_ps(WIFI_PS_NONE)(jpp_wifi_init.c); and BLE controller suspend while any HTTP server runs — the main-loop server-state poll (app_main.c) callsjpp_ble_native_suspend()/_resume()when WebDAV or the LRV server starts/stops (gated onfileserverstate ORjpp_lrv_server_is_running()), freeing the NimBLE controller's heap (safe because both servers and SD apps are mutually exclusive, so no app uses BLE then; WebDAV and LRV are mutually exclusive with each other too). Global diagnostics:jpp_heap_monitor(started first thing inapp_main) logs any failed allocation (ALLOC FAILED ..., tagheap_mon) and warns/errors on sustained low free heap — use this first when chasing a "network died" or OOM report.jpp_fileserver_coreemitsheap @webdav-start/webdav-stopevent markers viajpp_heap_monitor_log(). The app memory pool (jpp_app_pool,JPP_APP_POOL_BYTES= 80 KB static BSS) holds native app code, the MicroPython GC heap, or a running HTTP server (one at a time — all three are foreground activities and mutually exclusive) and cannot shrink below ~MeetApp's ~50 KB footprint; one shared pool instead of separate exec + GC + server pools keeps the reserved footprint minimal (every static KB comes out of the same heap WiFi/lwIP draw frame buffers from). The 64 → 80 KB raise is a deliberate trade of free heap for app capacity — it is 16 KB the WiFi driver can no longer have, partly offset by the HTTP servers no longer mallocing ~12 KB while running.ESP_IDF_CONTRACT.mdholds free heap after boot to a 64 KB floor; re-check theheap_monboot line on hardware before raising it further.