Skip to content

Latest commit

 

History

History
332 lines (303 loc) · 101 KB

File metadata and controls

332 lines (303 loc) · 101 KB

PROJECT KNOWLEDGE BASE

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.

DOCUMENTATION MAINTENANCE (NON-NEGOTIABLE)

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.

OVERVIEW

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.

STRUCTURE

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

WHERE TO LOOK

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: publishedrelease.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

CODE MAP

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_03), 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.

CONVENTIONS

  • The 5th keypad button is called OK, never CENTER or CTR. It was renamed throughout the codebase, docs, and SDK (JPP_SDK_KEY_CENTERJPP_SDK_KEY_OK, jpp_sdk_claim_centerjpp_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 (formerly claim_center) plus the JPP_SDK_KEY_OK*/JPP_SDK_OK_CLAIM_* constants it uses — kept the old names as deprecated aliases (same values, __attribute__((deprecated(...))) in C; a second s_symtab entry / jppsdk module-dict entry pointing at the same function) precisely because those names are the kind an already-compiled .bin/.mpy or external source tree can reference, and a straight rename there would have silently broken them (UNRESOLVED_SYM / AttributeError) with no sdk_min check able to catch it. sdk_min did still move: the new JPP_SDK_KEY_OK* names require level 3 (minted alongside wrap_text and 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 old CENTER-named forms keeps its original sdk_min: 2. See docs/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 is center_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, and idf.py flash for 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 the tools/docs image) — the idf.py commands 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.json and 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 (see scripts/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.csv lays out nvs (24 KB) + phy_init (4 KB), a factory app partition of 0x360000 (3,538,944 B), data_fs/runtime_fs SPIFFS (256 KB each, restored to their pre-2MB-squeeze size), and a restored coredump partition (64 KB) — the six partitions exactly fill the 4 MB flash, none left unpartitioned. Measured on develop right after the switch: image 0x1c55b0 = 1,856,944 B, leaving 0x19aa50 = 1,682,000 B (~1.6 MB, 47.5%) free in factory — 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 restored coredump partition 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 + mbedTLS ssl_*/x509_crt, ~47 KB) was already linked before HTTPS existed, pulled in by esp_http_client; only the CA blob was being garbage-collected for want of an esp_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 (see JPP_APP_POOL_BYTES and the WiFi/heap note below), and remains the tighter of the two constraints going forward.

  • All public jpp_core headers use #pragma once (no #ifndef guards).

  • Error return types: use jpp_<module>_result_t or jpp_<module>_status_t enums; never esp_err_t on 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 in jpp_string_util.h; do not duplicate them per-module. The same rule applies to main/ helpers: file slurps go through jpp_file_util, single-key NVS access through jpp_nvs_util, and screen headers through jpp_draw_util.

  • Named constants for all hardware defaults: battery in jpp_battery_core.h, keypad in jpp_keypad_core.h, OLED in jpp_oled_core.h, RTC in jpp_rtc_core.h, ADC resolution in jpp_hw_config.h, buzzer/DS1307 in jpp_hw_config.h.

  • Use NULL for pointer comparisons, not 0.

  • 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 and jpp_kbd_core only ever use the windowed 48-row region. The SDK context canvas[] 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 via jpp_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 (exporting jpp_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; api is 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) — requires files.full; browses /sd from root, ".." to go up, "/" suffix on dirs, marquee for long names; firmware counterpart is jpp_file_picker() in main/.

  • Backup settings: Settings > Backup settings — "Backup to SD card" writes /sd/backups/settings_YYYYMMDD_HHMMSS.json (NVS + settings.json); "Restore from file" invokes jpp_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, key username (string, max JPP_SETTINGS_USERNAME_MAX = 64 chars). Loaded at boot in load_username() (called from run_main_loop() after NVS init). Edited via Settings > User's name text 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 the name= parameter in the verification URL, and exposed to the App SDK (ungated) as the username field of jpp_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 NVS jpp_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 as jpp_keyboard_input()/the serial-manager consent screen): (1) welcome + "This is unit NN/20" (shown only if jpp_lrv_has_data() and jpp_lrv_get_run_size() succeeds) + "Press OK to set username" (OK only — not skippable); (2) username via jpp_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 from run_main_loop() right after load_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 from rtc on the left (jpp_rtc_get_current(), --:-- when unavailable), battery percentage flush right from shell->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 returns JPP_UI_ACTION_NONE on timeout so the clock ticks while a screen sits idle. Onboarding blocks the main loop, so run_main_loop() seeds shell status with one RTC hw read + one jpp_battery_read() (under s_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_GPIO in jpp_hw_config.h) — the only GPIO not claimed by the documented pin map. Driven via jpp_led_core (RMT TX, hand-rolled encoder, no led_strip dependency). 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, keys dummy_en (u8, 1=enabled) and dummy_app_id (str, SD app ID). Loaded at boot in load_dummy_mode() in app_main.c. When enabled: all launcher navigation is blocked, the locked app is auto-launched on boot and re-pushed after each exit, and the dummy_mode bool is set on jpp_sdk_context_t so apps can call jpp_sdk_is_dummy_mode(). Disabled by holding OK at boot — detected by check_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_held flag), 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 via settings_do_dummy_mode_save(true, app_id) in jpp_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. The Settings > * Device Info * section appears when jpp_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 NVS jpp_user/username; built by jpp_lrv_build_challenge()). The hwid field is the device's eFuse MAC (esp_efuse_mac_get_default()). run_size/device_type live 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-SMP PROVISION_LRV 0x30) is compiled in only when CONFIG_JPP_LRV_PROVISIONING=y (see main/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 with scripts/build_images.sh (production → build/, provisioning → build-prov/ via the sdkconfig.prov fragment), then run scripts/prepare_device.py --config scripts/mfg.toml for 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 as hwid via GET_INFO — no manual esptool.py chip_id; syncs the device RTC to the host clock via SET_TIME; write-once PROVISION_LRV; then uploads every app to the SD card so they survive the reflash), flashes the production image, auto-increments the serial from scripts/mfg.toml, and appends the unit (serial, hwid, pubkey, timestamp) to scripts/ledger.csv. No password is generated or printed — a provisioned unit needs no sticker. The lower-level scripts/lrv_manufacturing.py provision-device … (explicit --serial/--hwid) still exists for one-off/manual provisioning; both share the record builder make_identity_record().

  • Buzzer volume: persisted in NVS namespace jpp_sound, key buzzer_vol (u8). Discrete steps: 0 / 25 / 50 / 75 / 100. Implemented via gpio_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() in app_main.c, after nvs_flash_init). Changed at runtime via settings_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, key startup_jingle (u8, jpp_startup_jingle_t). Loaded alongside buzzer_vol in load_buzzer_volume(). Changed at runtime via settings_do_jingle_change(). The boot startup sound in app_main.c calls jpp_buzzer_play_startup_jingle_async(s_startup_jingle) (async, so the launcher comes up while it plays) instead of the fixed JPP_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, key back_gesture (u8, jpp_keypad_back_gesture_t: 0 = Hold [default], 1 = Double-click). Loaded at boot via load_back_gesture() in app_main.c (alongside load_buzzer_volume()) into the file-scope s_back_gesture_mode, which lives next to keypad_task because that is its only consumer. Changed at runtime via settings_do_back_gesture_change() in Settings > 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 until JPP_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. See jpp_keypad_core and 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 acquires jpp_app_pool at 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 the STOP_TIMEOUT path 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() in main/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 raw OK_LONG/OK_DOUBLE events become a Back action, a raw app key, or nothing (keypad_handle_ok_gesture()). Do not push this decision back down into jpp_keypad_core (it is a detector) or into jpp_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: see jpp_sdk_claim_ok under jpp_sdk_bridge.

  • SDK-visible types are append-only. Native apps are separately-built ELF32 binaries loaded from /sd/apps/… against whatever jpp_sdk_bridge.h looked like when they were compiled, and they read jpp_sdk_context_t fields directly (apps/demoscene/src/demoscene.c and apps/games/src/games_gfx.c both read canvas_fullscreen). Adding a field anywhere but the tail of that struct — or renumbering jpp_sdk_key_event_t / any other enum an app can see — silently shifts the offsets an already-deployed .bin was 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 (as JPP_SDK_KEY_BACK does for JPP_SDK_KEY_OK_LONG) is free. Adding a new jpp_sdk_* function is safe but needs a matching s_symtab entry in jpp_native_symtab.c or apps calling it die at launch with UNRESOLVED_SYM. This applies to jpp_sdk_native_services_t too, and more sharply: that struct is embedded by value near the top of jpp_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 inserting net_connect mid-struct. It is now frozen at its v1 shape; new service callbacks go in jpp_sdk_services_v2_t, which sits at the tail of jpp_sdk_context_t and is installed by jpp_sdk_set_services_v2() after bind. tests/test_sdk_abi.py pins both field lists, so a violation fails the host tests instead of silently corrupting deployed apps; it also cross-checks the C capability whitelist and JPP_SDK_VERSION against the Python mirror, after network.connect shipped in tests/validate_manifests.py without ever being added to jpp_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() in app_main.c.

  • Firmware version string: JPPDOS_VERSION in main/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_char do long read/write up to that but no further. To send a larger payload, use the shared app-side helper apps/common/jpp_ble_msg.{c,h}: jpp_ble_msg_send() frames the data into ordered chunks (BEGIN[total_len,crc32] + DATA[seq,payload≤400]) over ble_write_char, and jpp_ble_msg_host_recv() reassembles them on the peer from ble_host_wait_write, verifying the CRC. Compiled into each app that uses it (add apps/common/jpp_ble_msg.c + -Iapps/common to the app's build_shared.py and 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.c is the jppsdk MicroPython module and must mirror every jpp_sdk_* call in jpp_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 has import instead, and the header already returns INVALID_STATE for MP apps) and jpp_sdk_push_key (a firmware-internal injection hook called only by keypad_task in main/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 (see docs/sdk-changelog.md level 3). Adding a new jpp_sdk_* function therefore means three registrations, not one, and skipping any of them fails differently: the s_symtab entry in jpp_native_symtab.c (miss it and native apps die at launch with UNRESOLVED_SYM), the binding + jppsdk_module_globals_table entry in jpp_mp_sdk_module.c, and a Q(name) line in components/micropython/qstrdefsport.h. That last one is the easy one to miss: jpp_mp_sdk_module.c is compiled outside the micropython component and so is never scanned by makeqstrdefs.py, meaning every MP_QSTR_xxx it 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 use mp_get_buffer_raise/mp_obj_new_bytes; size-varying output buffers come from the MicroPython GC heap via m_new/m_del (which lives in the app pool, where app-owned data belongs) rather than new static .bss, since .bss is the scarcer budget — see the flash/RAM note below.

  • SDK versioning: the native bridge exports a single API level JPP_SDK_VERSION (in jpp_manifest_core.h, currently 3). A manifest declares the minimum level it needs via sdk_min (integer ≥ 1); jpp_manifest_v2_validate() rejects an app whose sdk_min exceeds JPP_SDK_VERSION with JPP_MANIFEST_SDK_TOO_OLD (surfaced through the LAUNCH_FAILED/MANIFEST_REJECTED path). sdk_min is a floor only — there is no sdk_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. Bump JPP_SDK_VERSION whenever 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 by jpp_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 leave sdk_min: 2 meaning two different surfaces in the field.

    Choosing the number (contribution policy). Set JPP_SDK_VERSION to (last released level) + 1not (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 bump 23 merge cleanly to 3 (base 2, both sides 3), which is the intended outcome, not a collision to avoid. Keep the per-level entries in docs/sdk-changelog.md and the sdk_min row in docs/manifest.md list-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 the UNRESOLVED_SYM class 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 mirror tests/validate_manifests.py (SDK_VERSION and ALLOWED_CAPABILITIES) in sync with the C constant and jpp_manifest_v2_is_allowed_capability(), same as the other manifest rules — tests/test_sdk_abi.py enforces both. Every level bump also gets a section in docs/sdk-changelog.md (the per-level record of what was added, and the page sdk_min guidance points at) plus a row in the sdk_min table in docs/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_confirm also only became resolvable at level 2, having been declared but omitted from s_symtab at v1.0-RTM. jpp_sdk_wrap_text was the same defect and is what mints level 3 — declared and documented since v1.0-RTM but absent from s_symtab, so every native app calling it died with UNRESOLVED_SYM. Adding a symbol to s_symtab is an SDK-surface change even when the function itself is untouched: what changed is its reachability from a loaded app binary, and sdk_min is 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() in main/jpp_app_dispatch.c only 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 via jpp_sdk_ensure_cap()consent_prompt_cbjpp_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.full per path (path_prompt, never persisted) and https.request per origin (origin_promptjpp_app_origin_prompt(), persisted one-per-line to /data/grants/<app_id>.origins — a separate file because grant_persist() rebuilds the .json from the fixed TIER1_CAPS array and would drop anything else stored there). During headless background runs jpp_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, and jpp_sdk_ensure_cap() logs every denial (user-declined, not-declared-in-manifest, or grant-overflow) under tag jpp_sdk. prompt_permission() builds the request lines and delegates to jpp_sdk_confirm(), which renders via jpp_sdk_set_frame + jpp_sdk_wait_key (runs from the app task; must NOT use SSD1306 directly or s_action_queue). An -Wunused-function warning on prompt_permission indicates jpp_app_consent_prompt() has been broken. NOTE: a manifest may declare up to SD_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. When jpp_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.

ANTI-PATTERNS (THIS PROJECT)

  • 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 in main/.
  • Do not make apply_consent() auto-grant capabilities without user confirmation. The interactive prompt_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 of apply_consent() must keep the tier-1/tier-2 rules intact (see CONVENTIONS above).

BUILTIN APPS

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/

UNIQUE STYLES

  • Native code should stay organized around ESP-IDF components and app/main entrypoints.
  • Host-side checks live under tests/; scenario fixtures under tests/fixtures/.
  • Keep docs concise and factual; prefer repo-backed commands over aspirational tooling.

COMMANDS

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.

JPPD-SMP WIRE FORMAT

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.

NOTES

  • 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 optionaljpp_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 (see jpp_lrv), chosen because the external chip survives factory reset and firmware reflash and binds the identity to the RTC module. Constants JPP_HW_EEPROM_I2C_ADDR / JPP_HW_EEPROM_SIZE_BYTES in jpp_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_3 after init — use jpp_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 alongside http.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 alljpp_http_server_core takes its task stack and its 32 KB I/O buffer from jpp_app_pool, which is why esp_http_server was replaced rather than tuned (httpd_start() mallocs its stack, socket table and scratch, and httpd_config_t.task_caps only 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) calls jpp_ble_native_suspend()/_resume() when WebDAV or the LRV server starts/stops (gated on fileserver state OR jpp_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 in app_main) logs any failed allocation (ALLOC FAILED ..., tag heap_mon) and warns/errors on sustained low free heap — use this first when chasing a "network died" or OOM report. jpp_fileserver_core emits heap @webdav-start/webdav-stop event markers via jpp_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.md holds free heap after boot to a 64 KB floor; re-check the heap_mon boot line on hardware before raising it further.