From ccb3be311fce67de8b2241259a511fed3251991e Mon Sep 17 00:00:00 2001 From: Georgiy Panin Date: Mon, 27 Jul 2026 19:54:10 +0700 Subject: [PATCH 1/5] Add double-click Back gesture and per-app SDK control over it Settings > Controls now lets the user pick Hold or Double-click as the CENTER Back trigger; native apps can additionally suppress delivery (jpp_sdk_set_back_gesture_enabled) or force Hold semantics for themselves regardless of the system preference (jpp_sdk_set_force_hold_back_gesture), so apps that overload CENTER as a primary action button aren't disrupted by accidental long-holds or double-taps. --- AGENTS.md | 12 +- apps/testapp_native/src/testapp_native.c | 16 ++- components/jpp_core/include/jpp_keypad_core.h | 18 +++ components/jpp_core/include/jpp_sdk_bridge.h | 25 ++++ components/jpp_core/src/jpp_keypad_core.c | 128 ++++++++++++++---- components/jpp_core/src/jpp_sdk_bridge.c | 17 +++ .../src/jpp_native_symtab.c | 3 + docs/sdk-reference.md | 44 +++++- main/app_main.c | 76 +++++++++-- main/jpp_backup_restore.c | 12 ++ main/jpp_settings_screen.c | 31 ++++- main/jpp_settings_screen.h | 7 + 12 files changed, 345 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d0115e2..2cb601a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ jppdos/ | 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, SD card, Backup settings, Factory Reset, **\* Device Info \*** (hidden unless LRV data present), **User's name**, **Dummy Mode**, About) | +| 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 | @@ -68,13 +68,14 @@ jppdos/ | `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, so re-enable it after a dialog/list. `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`. Ungated surface: storage, KV, IPC, device status (now includes `username`), get_time, is_dummy_mode, UI/buzzer/LED/wakelock/canvas/module-load. `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)` (native only) 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_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, so re-enable it after a dialog/list. `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`. Ungated surface: storage, KV, IPC, device status (now includes `username`), get_time, is_dummy_mode, UI/buzzer/LED/wakelock/canvas/module-load. `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)` (native only) 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_set_back_gesture_enabled(ctx, enabled)` (native only, ungated, defaults to `true` on every bind) lets an app drop the CENTER "Back" gesture (long-press or double-click, per Settings > Controls) instead of receiving it as `JPP_SDK_KEY_CENTER_LONG` — for apps that overload CENTER as a primary action button, so an accidental hold/double-tap mid-action doesn't yank the player into a pause/exit screen; the app re-enables it before/while showing its own pause or exit confirmation. Implemented as a plain `bool` on `jpp_sdk_context_t`, checked in `keypad_task` (`main/app_main.c`) before forwarding the gesture — does not touch UP/DOWN/LEFT/RIGHT/OK and has no effect on launcher/Settings navigation. `jpp_sdk_set_force_hold_back_gesture(ctx, force)` (native only, ungated, defaults to `false`) goes further: while forced, `keypad_task` builds the shared `jpp_keypad_config_t` passed to `jpp_keypad_poll()` with `back_gesture` pinned to `JPP_KEYPAD_BACK_GESTURE_HOLD` for as long as this app is foregrounded, overriding the system-wide Settings > Controls preference (Hold vs Double-click) at the physical-detector level rather than just at delivery — so a Double-click-mode user still gets instant "OK"/fire (no double-click defer) and only a deliberate hold (never a double-tap) reaches this app as `JPP_SDK_KEY_CENTER_LONG`. The launcher and every other app keep using the system preference unaffected; it reverts automatically when the app exits. DOOM-lite (see BUILTIN APPS) is the reference user for both calls. | | `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`, 64 KB) for the running app: executable code for native apps **or** the MicroPython GC heap. `jpp_app_pool_acquire()`/`_release()`/`_in_use()`. Native and MP apps are mutually exclusive, so one pool serves both; sharing one pool instead of reserving separate exec + GC pools keeps the static footprint minimal. 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`/`CENTER_SHORT`/`CENTER_LONG`/`CENTER_DOUBLE`). CENTER's "Back" trigger is configurable via `jpp_keypad_config_t.back_gesture` (`JPP_KEYPAD_BACK_GESTURE_HOLD`, default — hold ≥`long_press_ms` (700 ms), zero-latency OK on short release; or `JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK` — a short click's OK is deferred until `double_click_ms` (300 ms) passes with no second click; a second short click inside that window fires `CENTER_DOUBLE`/"Back" instead, with no OK). Driven every 20 ms by `keypad_task` in `main/app_main.c`, which holds the live config in the file-scope `s_kpad_ctx` so `Settings > Controls` can flip `back_gesture` at runtime with no task restart. | | `jpp_buzzer_core` | component | `components/jpp_core/` | LEDC buzzer driver; predefined sounds + custom tone/sequence API. `jpp_buzzer_set_volume(percent)` / `jpp_buzzer_get_volume()` — volume is controlled via GPIO drive capability (`GPIO_DRIVE_CAP_0`–`3`), not duty cycle; duty stays fixed at 50% (`JPP_HW_BUZZER_DUTY`) for all non-zero levels so waveform quality is unchanged. 0% mutes by setting duty to 0. Default after init is 100% (CAP_3). `load_buzzer_volume()` in `app_main.c` applies the persisted level before the startup chime. `jpp_startup_jingle_t` enum (0–10): DEFAULT, WINXP, WIN31, MAC, RICKROLL, NOKIA_ON, NOKIA_TUNE, SANDSTORM, DOOM, CLUTTERFUNK, OFF. `jpp_buzzer_play_startup_jingle(jingle)` plays the chosen jingle (no-op for OFF). `jpp_startup_jingle_name(jingle)` returns the display string. Playback comes in **blocking** (`jpp_buzzer_tone`/`_play_sequence`/`_play`/`_play_startup_jingle`) and **async** (`jpp_buzzer_play_sequence_async`/`_play_async`/`_play_startup_jingle_async`) forms: async copies the sequence into an inbox and hands it to a dedicated static-allocated player task, returning immediately. Submitting a new async sequence — or calling `jpp_buzzer_stop()` — preempts whatever is playing (generation counter checked between notes), so cycling previews cut the previous one off within one note. Blocking `_play_sequence` also preempts any async sequence so the two never drive the LEDC channel at once. Settings jingle previews and the boot chime use the async form. | | `jpp_led_core` | component | `components/jpp_core/` | onboard WS2812 (GPIO8, single pixel) driver using the RMT TX peripheral directly (hand-rolled bit encoder, no `led_strip` managed-component dependency — keeps flash footprint minimal). `jpp_led_init()` is idempotent and also called lazily on first `jpp_led_set_color()`/`_off()`. Backs the ungated `jpp_sdk_led_*` SDK surface. | | `jpp_espnow_native` | component | `components/jpp_core/` | ESP-NOW send/receive driver backing `esp_now` (tier 1). Mirrors `jpp_ble_native.c`: `jpp_core` cannot depend on `main/`, so this module brings up the WiFi driver itself (STA mode, idempotent — same calls as `wifi_ensure_started()` in `main/jpp_wifi_init.c`, tolerant of "already running") rather than reaching into `main/`. Send blocks on a semaphore signalled by the ESP-NOW send callback (bounded timeout); receive pulls from an 8-entry queue fed by the recv callback — a packet is dropped if the app doesn't drain the queue often enough. `jpp_espnow_native_get_services()` follows the same `_get_services()` out-param pattern as `jpp_ble_native_get_services()`. | @@ -87,7 +88,7 @@ jppdos/ | `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), 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 CENTER 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_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 CENTER 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 | @@ -117,7 +118,7 @@ jppdos/ - 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. - 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//` 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`) 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. +- 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)` (`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. - 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)`. @@ -126,6 +127,7 @@ jppdos/ - 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()`), applied to the file-scope keypad task context `s_kpad_ctx.cfg.back_gesture`. Changed at runtime via `settings_do_back_gesture_change()` in `Settings > Controls` (one row, LEFT/RIGHT toggles) — takes effect immediately since `keypad_task` re-reads `s_kpad_ctx.cfg` every 20 ms poll, no restart needed. In **Hold** mode, behavior is unchanged from before this setting existed: holding CENTER ≥`JPP_KEYPAD_DEFAULT_LONG_PRESS_MS` (700 ms) fires Back (with auto-repeat while held), and a short release fires OK instantly. In **Double-click** mode, a short CENTER release defers OK until `JPP_KEYPAD_DEFAULT_DOUBLE_CLICK_MS` (300 ms) passes with no second click; if a second short click lands within that window the pair fires `CENTER_DOUBLE`/Back instead, with no OK for either click; holding CENTER produces no event in this mode. See `jpp_keypad_core`. - 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. @@ -142,7 +144,7 @@ jppdos/ ## 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), SD card, Backup settings, Factory Reset, \* Device Info \* (LRV only), User's name, Dummy Mode (single-app lock; hold CENTER at boot to disable), About | +| `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 CENTER 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 | | SD apps | (discovered) | `/sd/apps//manifest.json` — MicroPython or native C binary | | `testapp_native` | SDK Test (C) | Native C test app; exercises every SDK capability via a menu; `apps/testapp_native/` | diff --git a/apps/testapp_native/src/testapp_native.c b/apps/testapp_native/src/testapp_native.c index b4f4027..5c3cb33 100644 --- a/apps/testapp_native/src/testapp_native.c +++ b/apps/testapp_native/src/testapp_native.c @@ -776,17 +776,27 @@ static void test_background_register(jpp_sdk_context_t *ctx) : r.code); } +static void test_back_gesture(jpp_sdk_context_t *ctx) +{ + jpp_sdk_status_t st = jpp_sdk_set_back_gesture_enabled(ctx, false); + show_result(ctx, "set_back_gesture_enabled", st, "disabled"); + if (st != JPP_SDK_STATUS_OK) return; + st = jpp_sdk_set_back_gesture_enabled(ctx, true); + show_result(ctx, "set_back_gesture_enabled", st, "re-enabled"); +} + static void menu_system(jpp_sdk_context_t *ctx) { static const char *items[] = { - "Wakelock acq/rel", "Log event", "Background register" + "Wakelock acq/rel", "Log event", "Background register", "Back gesture toggle" }; for (;;) { - int sel = pick(ctx, "System Tests", items, 3); + int sel = pick(ctx, "System Tests", items, 4); if (sel < 0) return; if (sel == 0) test_wakelock(ctx); else if (sel == 1) test_log(ctx); - else test_background_register(ctx); + else if (sel == 2) test_background_register(ctx); + else test_back_gesture(ctx); } } diff --git a/components/jpp_core/include/jpp_keypad_core.h b/components/jpp_core/include/jpp_keypad_core.h index 0bf945c..1c49fe1 100644 --- a/components/jpp_core/include/jpp_keypad_core.h +++ b/components/jpp_core/include/jpp_keypad_core.h @@ -14,6 +14,15 @@ extern "C" { #define JPP_KEYPAD_DEFAULT_POLL_INTERVAL_MS 100 #define JPP_KEYPAD_DEFAULT_REPEAT_DELAY_MS 500 #define JPP_KEYPAD_DEFAULT_REPEAT_INTERVAL_MS 500 +#define JPP_KEYPAD_DEFAULT_DOUBLE_CLICK_MS 300 + +/* Selects how CENTER triggers "Back": holding for long_press_ms (default, + zero-latency OK), or two short clicks within double_click_ms (defers OK + until the window passes with no second click). Mutually exclusive. */ +typedef enum { + JPP_KEYPAD_BACK_GESTURE_HOLD = 0, + JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK, +} jpp_keypad_back_gesture_t; typedef enum { JPP_KEYPAD_KIND_NO_EVENT = 0, @@ -22,6 +31,7 @@ typedef enum { JPP_KEYPAD_KIND_REPEAT, JPP_KEYPAD_KIND_CENTER_SHORT, JPP_KEYPAD_KIND_CENTER_LONG, + JPP_KEYPAD_KIND_CENTER_DOUBLE, } jpp_keypad_event_kind_t; typedef struct { @@ -41,6 +51,8 @@ typedef struct { bool repeat_enabled; int repeat_delay_ms; int repeat_interval_ms; + jpp_keypad_back_gesture_t back_gesture; + int double_click_ms; const jpp_keypad_band_t *bands; size_t band_count; } jpp_keypad_config_t; @@ -70,6 +82,12 @@ typedef struct { int last_repeat_ms; bool center_long_emitted; size_t sample_index; + /* Double-click back-gesture: a deferred short click waiting to see if a + second click follows within double_click_ms. Survives across the idle + gap between two separate press/release cycles, unlike press_started_ms + above which jpp_keypad_reset_hold_state() clears on every release. */ + bool ok_pending; + int pending_release_ms; } jpp_keypad_state_t; void jpp_keypad_state_init(jpp_keypad_state_t *state, const jpp_keypad_config_t *config); diff --git a/components/jpp_core/include/jpp_sdk_bridge.h b/components/jpp_core/include/jpp_sdk_bridge.h index c696a16..e61700c 100644 --- a/components/jpp_core/include/jpp_sdk_bridge.h +++ b/components/jpp_core/include/jpp_sdk_bridge.h @@ -516,6 +516,8 @@ typedef struct { bool bound; bool wakelock_held; /* prevents screen dim / deep sleep when true */ bool dummy_mode; /* set when the app is launched as the dummy-mode locked app */ + bool back_gesture_enabled; /* true by default; see jpp_sdk_set_back_gesture_enabled() */ + bool force_hold_back_gesture; /* false by default; see jpp_sdk_set_force_hold_back_gesture() */ QueueHandle_t key_queue; /* FreeRTOS queue, capacity 8, element jpp_sdk_key_event_t */ /* Pixel canvas — row-major, MSB = leftmost pixel. Rows 0–47 in windowed mode (pages 2–7); all 64 rows when canvas_fullscreen is set. */ @@ -973,6 +975,29 @@ jpp_sdk_status_t jpp_sdk_wait_key(jpp_sdk_context_t *context, uint32_t timeout_m /* Called by the main loop to push a key event into the active app's queue. */ void jpp_sdk_push_key(jpp_sdk_context_t *context, jpp_sdk_key_event_t event); +/* Ungated — when enabled (the default), a CENTER long-press or double-click + (whichever gesture Settings > Controls has selected) is delivered to the + app as JPP_SDK_KEY_CENTER_LONG. When disabled, that gesture is dropped + silently: the app receives nothing for it. Use this to stop an accidental + long hold/double-click on a primary action button (e.g. a shoot button) + from being misread as "Back" during fast gameplay — disable while the + button means something else, re-enable before/while showing your own + pause or exit-confirmation screen. Does not affect UP/DOWN/LEFT/RIGHT/OK, + and has no effect on the launcher/Settings navigation outside your app. */ +jpp_sdk_status_t jpp_sdk_set_back_gesture_enabled(jpp_sdk_context_t *context, bool enabled); + +/* Ungated — while forced (the default is off), CENTER's "Back" trigger is + always evaluated as a hold (JPP_KEYPAD_BACK_GESTURE_HOLD) for this app's + key stream, regardless of the system-wide Settings > Controls preference + (Hold vs Double-click), which keeps governing the launcher and every other + app. Use this when your app overloads CENTER as a primary action button + (e.g. "fire"): it removes the double-click window's inherent delay on the + short-click ("OK"/fire) delivery, and guarantees a long hold — never a + double-tap — is what reaches you as JPP_SDK_KEY_CENTER_LONG. Reverts to + the system preference automatically when your app exits. Has no effect + outside your app. */ +jpp_sdk_status_t jpp_sdk_set_force_hold_back_gesture(jpp_sdk_context_t *context, bool force); + /* ---- High-level UI helpers (no capability required) ---------------------- */ /* diff --git a/components/jpp_core/src/jpp_keypad_core.c b/components/jpp_core/src/jpp_keypad_core.c index 022bd2d..100c51d 100644 --- a/components/jpp_core/src/jpp_keypad_core.c +++ b/components/jpp_core/src/jpp_keypad_core.c @@ -68,6 +68,53 @@ static int jpp_keypad_config_repeat_interval_ms(const jpp_keypad_config_t *confi return config->repeat_interval_ms; } +static jpp_keypad_back_gesture_t jpp_keypad_config_back_gesture(const jpp_keypad_config_t *config) +{ + if (config == NULL) { + return JPP_KEYPAD_BACK_GESTURE_HOLD; + } + return config->back_gesture; +} + +static int jpp_keypad_config_double_click_ms(const jpp_keypad_config_t *config) +{ + if (config == NULL || config->double_click_ms < 1) { + return JPP_KEYPAD_DEFAULT_DOUBLE_CLICK_MS; + } + return config->double_click_ms; +} + +/* Emits the deferred OK for a short click once double_click_ms has passed + with no second click. Called once per poll, before any other event logic, + so a click that finally times out resolves before a later click in the + same poll can start a fresh pending cycle. */ +static int jpp_keypad_check_pending_ok( + jpp_keypad_state_t *state, + const jpp_keypad_config_t *config, + int now_ms, + jpp_keypad_event_t *events, + size_t event_capacity, + size_t *event_count +) +{ + if (state == NULL || !state->ok_pending) { + return 0; + } + if (now_ms - state->pending_release_ms < jpp_keypad_config_double_click_ms(config)) { + return 0; + } + if (*event_count >= event_capacity) { + return -1; + } + state->ok_pending = false; + events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_SHORT; + events[*event_count].key = "CENTER"; + events[*event_count].mapped = "OK"; + events[*event_count].duration_ms = 0; + *event_count += 1u; + return 0; +} + static const jpp_keypad_band_t *jpp_keypad_band_by_key(const jpp_keypad_config_t *config, const char *key) { size_t band_count = 0u; @@ -206,6 +253,28 @@ static int jpp_keypad_finalize_release( duration_ms = 0; } if (strcmp(previous_key, "CENTER") == 0) { + if (jpp_keypad_config_back_gesture(config) == JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK) { + /* Long hold: the live branch in jpp_keypad_poll() never fires + CENTER_LONG in this mode, so there is nothing to finalize. */ + if (duration_ms < jpp_keypad_config_long_press_ms(config)) { + if (state->ok_pending && + now_ms - state->pending_release_ms <= jpp_keypad_config_double_click_ms(config)) { + if (*event_count >= event_capacity) { + return -1; + } + state->ok_pending = false; + events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_DOUBLE; + events[*event_count].key = previous_key; + events[*event_count].mapped = "BACK"; + events[*event_count].duration_ms = duration_ms; + *event_count += 1u; + } else { + state->ok_pending = true; + state->pending_release_ms = now_ms; + } + } + return 0; + } if (duration_ms >= jpp_keypad_config_long_press_ms(config) && !state->center_long_emitted) { if (*event_count >= event_capacity) { return -1; @@ -265,6 +334,8 @@ void jpp_keypad_state_init(jpp_keypad_state_t *state, const jpp_keypad_config_t state->repeat_interval_ms = config == NULL ? 150 : config->repeat_interval_ms; state->press_started_ms = -1; state->last_repeat_ms = -1; + state->ok_pending = false; + state->pending_release_ms = -1; state->ready = state->enabled; } @@ -283,6 +354,8 @@ const char *jpp_keypad_event_kind_name(jpp_keypad_event_kind_t kind) return "CENTER_SHORT"; case JPP_KEYPAD_KIND_CENTER_LONG: return "CENTER_LONG"; + case JPP_KEYPAD_KIND_CENTER_DOUBLE: + return "CENTER_DOUBLE"; } return "UNKNOWN"; } @@ -311,6 +384,9 @@ int jpp_keypad_poll( return 0; } now_ms = (int)state->sample_index * jpp_keypad_config_poll_interval_ms(config); + if (jpp_keypad_check_pending_ok(state, config, now_ms, events, event_capacity, event_count) != 0) { + return -1; + } if (!sample_present) { if (state->stable_key != NULL) { previous_key = state->stable_key; @@ -334,31 +410,35 @@ int jpp_keypad_poll( state->press_started_ms = now_ms; } if (strcmp(candidate_key, "CENTER") == 0) { - int duration_ms = now_ms - state->press_started_ms; - if (duration_ms >= jpp_keypad_config_long_press_ms(config) && !state->center_long_emitted) { - if (*event_count >= event_capacity) { - return -1; - } - state->center_long_emitted = true; - events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_LONG; - events[*event_count].key = candidate_key; - events[*event_count].mapped = "BACK"; - events[*event_count].duration_ms = duration_ms; - *event_count += 1u; - } else if (state->center_long_emitted && - now_ms - state->press_started_ms >= - jpp_keypad_config_long_press_ms(config) + - jpp_keypad_config_repeat_delay_ms(config) && - now_ms - state->last_repeat_ms >= jpp_keypad_config_repeat_interval_ms(config)) { - if (*event_count >= event_capacity) { - return -1; + /* Double-click mode has no live-hold gesture: CENTER_DOUBLE + is decided on release in jpp_keypad_finalize_release(). */ + if (jpp_keypad_config_back_gesture(config) == JPP_KEYPAD_BACK_GESTURE_HOLD) { + int duration_ms = now_ms - state->press_started_ms; + if (duration_ms >= jpp_keypad_config_long_press_ms(config) && !state->center_long_emitted) { + if (*event_count >= event_capacity) { + return -1; + } + state->center_long_emitted = true; + events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_LONG; + events[*event_count].key = candidate_key; + events[*event_count].mapped = "BACK"; + events[*event_count].duration_ms = duration_ms; + *event_count += 1u; + } else if (state->center_long_emitted && + now_ms - state->press_started_ms >= + jpp_keypad_config_long_press_ms(config) + + jpp_keypad_config_repeat_delay_ms(config) && + now_ms - state->last_repeat_ms >= jpp_keypad_config_repeat_interval_ms(config)) { + if (*event_count >= event_capacity) { + return -1; + } + state->last_repeat_ms = now_ms; + events[*event_count].kind = JPP_KEYPAD_KIND_REPEAT; + events[*event_count].key = candidate_key; + events[*event_count].mapped = "BACK"; + events[*event_count].duration_ms = 0; + *event_count += 1u; } - state->last_repeat_ms = now_ms; - events[*event_count].kind = JPP_KEYPAD_KIND_REPEAT; - events[*event_count].key = candidate_key; - events[*event_count].mapped = "BACK"; - events[*event_count].duration_ms = 0; - *event_count += 1u; } } else if (jpp_keypad_emit_repeat_if_needed(state, config, candidate_key, now_ms, events, event_capacity, event_count) != 0) { return -1; diff --git a/components/jpp_core/src/jpp_sdk_bridge.c b/components/jpp_core/src/jpp_sdk_bridge.c index 9046ed9..63b8725 100644 --- a/components/jpp_core/src/jpp_sdk_bridge.c +++ b/components/jpp_core/src/jpp_sdk_bridge.c @@ -335,6 +335,7 @@ void jpp_sdk_context_init(jpp_sdk_context_t *context) } memset(context, 0, sizeof(*context)); context->key_queue = xQueueCreate(8u, sizeof(jpp_sdk_key_event_t)); + context->back_gesture_enabled = true; } jpp_sdk_status_t jpp_sdk_bind( @@ -2549,6 +2550,22 @@ void jpp_sdk_push_key(jpp_sdk_context_t *context, jpp_sdk_key_event_t event) xQueueSendToBack(context->key_queue, &event, 0); } +jpp_sdk_status_t jpp_sdk_set_back_gesture_enabled(jpp_sdk_context_t *context, bool enabled) +{ + jpp_sdk_status_t status = jpp_sdk_ensure_bound(context); + if (status != JPP_SDK_STATUS_OK) { return status; } + context->back_gesture_enabled = enabled; + return JPP_SDK_STATUS_OK; +} + +jpp_sdk_status_t jpp_sdk_set_force_hold_back_gesture(jpp_sdk_context_t *context, bool force) +{ + jpp_sdk_status_t status = jpp_sdk_ensure_bound(context); + if (status != JPP_SDK_STATUS_OK) { return status; } + context->force_hold_back_gesture = force; + return JPP_SDK_STATUS_OK; +} + /* -------------------------------------------------------------------------- */ /* High-level UI helpers: Dialog, List, Input */ /* */ diff --git a/components/jpp_native_loader_core/src/jpp_native_symtab.c b/components/jpp_native_loader_core/src/jpp_native_symtab.c index 1055479..2016023 100644 --- a/components/jpp_native_loader_core/src/jpp_native_symtab.c +++ b/components/jpp_native_loader_core/src/jpp_native_symtab.c @@ -64,12 +64,15 @@ static const jpp_native_sym_t s_symtab[] = { { "jpp_sdk_dialog", (void *)jpp_sdk_dialog }, { "jpp_sdk_list", (void *)jpp_sdk_list }, { "jpp_sdk_input", (void *)jpp_sdk_input }, + { "jpp_sdk_confirm", (void *)jpp_sdk_confirm }, { "jpp_sdk_file_pick", (void *)jpp_sdk_file_pick }, /* Key input */ { "jpp_sdk_poll_key", (void *)jpp_sdk_poll_key }, { "jpp_sdk_wait_key", (void *)jpp_sdk_wait_key }, { "jpp_sdk_push_key", (void *)jpp_sdk_push_key }, + { "jpp_sdk_set_back_gesture_enabled", (void *)jpp_sdk_set_back_gesture_enabled }, + { "jpp_sdk_set_force_hold_back_gesture", (void *)jpp_sdk_set_force_hold_back_gesture }, /* Canvas */ { "jpp_sdk_canvas_clear", (void *)jpp_sdk_canvas_clear }, diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 03dad12..1b31fe3 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -6,7 +6,7 @@ |----------|-----------| | [Types and constants](#types-and-constants) | [`jpp_sdk_status_t`](#c--jpp_sdk_status_t) · [`jpp_sdk_key_event_t`](#c--jpp_sdk_key_event_t) · [Python constants](#python--jppsdk-constants) · [`jpp_broker_result_t`](#c--jpp_broker_result_t) | | [App control](#app-control) | [`set_frame`](#set_frame) · [`request_close`](#request_close) · [`log`](#log) · [`request_cap`](#request_cap-c-only) | -| [Key input](#key-input) | [`poll_key`](#poll_key) · [`wait_key`](#wait_key) · [`push_key`](#push_key-c-only) | +| [Key input](#key-input) | [`poll_key`](#poll_key) · [`wait_key`](#wait_key) · [`push_key`](#push_key-c-only) · [`set_back_gesture_enabled`](#set_back_gesture_enabled) · [`set_force_hold_back_gesture`](#set_force_hold_back_gesture) | | [Canvas](#canvas) | [`canvas_write`](#canvas_write) · [`canvas_draw_pixel`](#canvas_draw_pixel) · [`canvas_clear`](#canvas_clear) · [`canvas_fullscreen`](#canvas_fullscreen) | | [UI helpers](#ui-helpers) | [`dialog`](#dialog) · [`list`](#list) · [`input`](#input) · [`confirm`](#confirm) · [`file_pick`](#file_pick) · [`wrap_text`](#wrap_text-c-only) | | [Wakelock](#wakelock) | [`wakelock_acquire`](#wakelock_acquire) · [`wakelock_release`](#wakelock_release) | @@ -267,6 +267,48 @@ void jpp_sdk_push_key(jpp_sdk_context_t *ctx, jpp_sdk_key_event_t event); --- +### `set_back_gesture_enabled` (C only) + +Enable or disable delivery of the "Back" gesture to your app. + +**Capability:** None + +```c +jpp_sdk_status_t jpp_sdk_set_back_gesture_enabled(jpp_sdk_context_t *ctx, bool enabled); +``` + +**Parameters:** + +| Name | Description | +|------|-------------| +| `enabled` | `true` (the default) delivers the gesture as `JPP_SDK_KEY_CENTER_LONG`; `false` drops it silently — your app receives nothing for it. | + +**Notes:** CENTER's "Back" trigger — a long hold or a double-click, whichever Settings > Controls has selected — always arrives as `JPP_SDK_KEY_CENTER_LONG`, same as today. If your app uses CENTER as a primary action button (e.g. "fire" in a shooter), an excited player can easily hold or double-tap it by accident and get yanked into a pause/exit screen mid-action. Call `set_back_gesture_enabled(ctx, false)` while that would be disruptive, and re-enable it before or while showing your own pause menu or exit confirmation, so there's still an explicit, discoverable way out. Disabling it does not affect `UP`/`DOWN`/`LEFT`/`RIGHT`/`OK`, and has no effect outside your app (the launcher and Settings are unaffected). Resets to enabled the next time your app is (re)bound. + +--- + +### `set_force_hold_back_gesture` (C only) + +Force CENTER's "Back" trigger to always be a hold for your app, regardless of the system-wide Settings > Controls preference. + +**Capability:** None + +```c +jpp_sdk_status_t jpp_sdk_set_force_hold_back_gesture(jpp_sdk_context_t *ctx, bool force); +``` + +**Parameters:** + +| Name | Description | +|------|-------------| +| `force` | `true` evaluates CENTER as a hold (`JPP_KEYPAD_BACK_GESTURE_HOLD`) for your app's key stream no matter what Settings > Controls says; `false` (the default) follows the system-wide preference like any other app. | + +**Notes:** If your app overloads CENTER as a primary action button, the system's Double-click preference is actively harmful to it in two ways: every short click ("OK"/fire) is deferred a few hundred ms waiting to see if a second click follows, and two rapid taps of your action button can pair up into an accidental "Back". Forcing Hold mode removes both problems — fire is instant, and only a deliberate hold ever reaches you as `JPP_SDK_KEY_CENTER_LONG`. This changes what the shared keypad detector does while your app is foregrounded; it has no effect on the launcher or any other app, and reverts to the system preference automatically the moment your app exits. See also `set_back_gesture_enabled`, which controls delivery rather than the underlying gesture detection — the two can be combined. + +--- + +--- + ## Canvas The canvas is a **128×48 pixel area** occupying OLED pages 2–7 (below the frame text). In fullscreen mode it expands to **128×64 pixels** covering the entire display. diff --git a/main/app_main.c b/main/app_main.c index b020937..7db08d9 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -199,6 +199,8 @@ typedef struct { jpp_keypad_state_t state; } keypad_task_ctx_t; +static keypad_task_ctx_t s_kpad_ctx; + static void keypad_task(void *arg) { keypad_task_ctx_t *ctx = (keypad_task_ctx_t *)arg; @@ -223,7 +225,14 @@ static void keypad_task(void *arg) jpp_keypad_event_t events[8]; size_t event_count = 0u; - jpp_keypad_poll(&ctx->state, &ctx->cfg, sample_uv, true, + jpp_keypad_config_t poll_cfg = ctx->cfg; + if (s_active_sdk_context != NULL && s_active_sdk_context->force_hold_back_gesture) { + /* The foreground app wants CENTER's Back trigger to always be a + hold, regardless of the user's Settings > Controls preference + (which keeps governing the launcher once this app exits). */ + poll_cfg.back_gesture = JPP_KEYPAD_BACK_GESTURE_HOLD; + } + jpp_keypad_poll(&ctx->state, &poll_cfg, sample_uv, true, events, 8u, &event_count); for (size_t i = 0u; i < event_count; i++) { @@ -247,7 +256,13 @@ static void keypad_task(void *arg) case JPP_UI_ACTION_BACK: sdk_key = JPP_SDK_KEY_NONE; break; case JPP_UI_ACTION_NONE: sdk_key = JPP_SDK_KEY_NONE; break; } - if (events[i].kind == JPP_KEYPAD_KIND_CENTER_LONG) { + if ((events[i].kind == JPP_KEYPAD_KIND_CENTER_LONG || + events[i].kind == JPP_KEYPAD_KIND_CENTER_DOUBLE) && + s_active_sdk_context->back_gesture_enabled) { + /* Apps only care that Back happened, not which + gesture triggered it — reuse the existing key. + Dropped entirely (sdk_key stays NONE) when the app + has suppressed it via jpp_sdk_set_back_gesture_enabled. */ sdk_key = JPP_SDK_KEY_CENTER_LONG; } if (sdk_key != JPP_SDK_KEY_NONE) { @@ -342,6 +357,7 @@ static void render_dim_clock(jpp_rtc_state_t *rtc_state) #define JPP_NVS_SOUND_NS "jpp_sound" #define JPP_NVS_USER_NS "jpp_user" #define JPP_NVS_DUMMY_NS "jpp_dummy" +#define JPP_NVS_INPUT_NS "jpp_input" typedef struct { bool enabled; @@ -610,6 +626,14 @@ static void settings_do_backup(jpp_settings_state_t *state) cJSON_AddStringToObject(nvs_user, "username", uname); } + cJSON *nvs_input = cJSON_CreateObject(); + if (nvs_open(JPP_NVS_INPUT_NS, NVS_READONLY, &h) == ESP_OK) { + uint8_t back_gesture = JPP_KEYPAD_BACK_GESTURE_HOLD; + nvs_get_u8(h, "back_gesture", &back_gesture); + nvs_close(h); + cJSON_AddNumberToObject(nvs_input, "back_gesture", (double)back_gesture); + } + /* Assemble the backup JSON. */ cJSON *root = cJSON_CreateObject(); cJSON_AddNumberToObject(root, "jppdos_backup", 1.0); @@ -619,6 +643,7 @@ static void settings_do_backup(jpp_settings_state_t *state) cJSON_AddItemToObject(root, "nvs_webdav", nvs_webdav); cJSON_AddItemToObject(root, "nvs_sound", nvs_sound); cJSON_AddItemToObject(root, "nvs_user", nvs_user); + cJSON_AddItemToObject(root, "nvs_input", nvs_input); /* LRV identity is intentionally NOT included in backups: it lives on the AT24C32 EEPROM (bound to the RTC module) and is neither user-backupable @@ -945,6 +970,33 @@ static void settings_do_jingle_change(uint8_t jingle) jingle, jpp_startup_jingle_name((jpp_startup_jingle_t)jingle)); } +/* ---- Back button gesture ------------------------------------------------- */ + +static uint8_t s_back_gesture_mode = JPP_KEYPAD_BACK_GESTURE_HOLD; + +static void load_back_gesture(void) +{ + uint8_t mode = jpp_nvs_get_u8(JPP_NVS_INPUT_NS, "back_gesture", s_back_gesture_mode); + if (mode == JPP_KEYPAD_BACK_GESTURE_HOLD || mode == JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK) { + s_back_gesture_mode = mode; + } + ESP_LOGI(TAG, "INPUT: back_gesture=%u", s_back_gesture_mode); +} + +/* Applied live: s_kpad_ctx is file-scope and the keypad task re-reads + ctx->cfg every poll, so this takes effect on the next 20ms tick with no + task restart. */ +static void settings_do_back_gesture_change(uint8_t mode) +{ + if (mode != JPP_KEYPAD_BACK_GESTURE_HOLD && mode != JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK) { + return; + } + s_back_gesture_mode = mode; + s_kpad_ctx.cfg.back_gesture = (jpp_keypad_back_gesture_t)mode; + jpp_nvs_set_u8(JPP_NVS_INPUT_NS, "back_gesture", mode); + ESP_LOGI(TAG, "INPUT: back_gesture changed to %u", mode); +} + /* ---- Dummy mode (single-app lock) --------------------------------------- */ static bool s_dummy_enabled = false; @@ -1090,10 +1142,9 @@ static void run_main_loop(jpp_ui_shell_t *shell, jpp_native_services_set_battery_state(&bat_state); /* Keypad task */ - static keypad_task_ctx_t kpad_ctx; - kpad_ctx.adc = adc; - kpad_ctx.adc_mutex = xSemaphoreCreateMutex(); - kpad_ctx.cfg = (jpp_keypad_config_t){ + s_kpad_ctx.adc = adc; + s_kpad_ctx.adc_mutex = xSemaphoreCreateMutex(); + s_kpad_ctx.cfg = (jpp_keypad_config_t){ .enabled = true, .calibration_uv = 0, .hysteresis_uv = JPP_KEYPAD_DEFAULT_HYSTERESIS_UV, @@ -1103,13 +1154,15 @@ static void run_main_loop(jpp_ui_shell_t *shell, .repeat_enabled = true, .repeat_delay_ms = JPP_KEYPAD_DEFAULT_REPEAT_DELAY_MS, .repeat_interval_ms = JPP_KEYPAD_DEFAULT_REPEAT_INTERVAL_MS, + .back_gesture = (jpp_keypad_back_gesture_t)s_back_gesture_mode, + .double_click_ms = JPP_KEYPAD_DEFAULT_DOUBLE_CLICK_MS, .bands = KEYPAD_BANDS, .band_count = sizeof(KEYPAD_BANDS) / sizeof(KEYPAD_BANDS[0]), }; - jpp_keypad_state_init(&kpad_ctx.state, &kpad_ctx.cfg); + jpp_keypad_state_init(&s_kpad_ctx.state, &s_kpad_ctx.cfg); s_action_queue = xQueueCreate(JPP_ACTION_QUEUE_DEPTH, sizeof(jpp_ui_action_t)); - xTaskCreate(keypad_task, "keypad", 4096, &kpad_ctx, + xTaskCreate(keypad_task, "keypad", 4096, &s_kpad_ctx, configMAX_PRIORITIES - 1, NULL); /* Settings screen state */ @@ -1134,6 +1187,7 @@ static void run_main_loop(jpp_ui_shell_t *shell, .do_text_input = settings_do_text_input, .do_volume_change = settings_do_volume_change, .do_jingle_change = settings_do_jingle_change, + .do_back_gesture_change = settings_do_back_gesture_change, .do_settings_backup = settings_do_backup, .do_settings_restore = settings_do_restore, .do_lrv_verify = settings_do_lrv_verify, @@ -1185,6 +1239,7 @@ static void run_main_loop(jpp_ui_shell_t *shell, /* Populate Sound section staging from values already loaded at boot. */ settings_state.sound_volume_pct = s_buzzer_volume_pct; settings_state.sound_jingle = s_startup_jingle; + settings_state.back_gesture_mode = s_back_gesture_mode; /* Load persisted NTP / timezone config and populate settings staging state. */ ntp_cfg_load(); @@ -1308,9 +1363,9 @@ static void run_main_loop(jpp_ui_shell_t *shell, /* Battery read every 5 seconds */ if (ui_tick % (5000u / JPP_UI_REFRESH_MS) == 0u) { - xSemaphoreTake(kpad_ctx.adc_mutex, portMAX_DELAY); + xSemaphoreTake(s_kpad_ctx.adc_mutex, portMAX_DELAY); jpp_battery_read(adc, &bat_cfg, &bat_state); - xSemaphoreGive(kpad_ctx.adc_mutex); + xSemaphoreGive(s_kpad_ctx.adc_mutex); int pct = bat_state.valid ? bat_state.percent : -1; /* Log only when the percentage changes — the 5 s poll otherwise floods the console with the same value. */ @@ -1933,6 +1988,7 @@ void app_main(void) /* Apply persisted buzzer volume before the startup chime. */ load_buzzer_volume(); + load_back_gesture(); /* Step 7 */ jpp_ui_shell_t shell; diff --git a/main/jpp_backup_restore.c b/main/jpp_backup_restore.c index 9cca4e8..c0d729f 100644 --- a/main/jpp_backup_restore.c +++ b/main/jpp_backup_restore.c @@ -16,6 +16,7 @@ static const char *TAG = "backup_restore"; #define NS_WEBDAV "jpp_webdav" #define NS_SOUND "jpp_sound" #define NS_USER "jpp_user" +#define NS_INPUT "jpp_input" bool jpp_backup_apply_json(const char *json_buf, char *msg, size_t msg_len) { @@ -104,6 +105,17 @@ bool jpp_backup_apply_json(const char *json_buf, char *msg, size_t msg_len) nvs_commit(h); nvs_close(h); } + /* jpp_input */ + cJSON *nvs_input = cJSON_GetObjectItem(root, "nvs_input"); + if (cJSON_IsObject(nvs_input) && + nvs_open(NS_INPUT, NVS_READWRITE, &h) == ESP_OK) { + cJSON *v = cJSON_GetObjectItem(nvs_input, "back_gesture"); + if (cJSON_IsNumber(v) && ((int)v->valuedouble == 0 || (int)v->valuedouble == 1)) { + nvs_set_u8(h, "back_gesture", (uint8_t)(int)v->valuedouble); + } + nvs_commit(h); nvs_close(h); + } + /* LRV identity is intentionally NOT restored from backups: it lives on the write-once AT24C32 EEPROM and is provisioned only at manufacturing. */ diff --git a/main/jpp_settings_screen.c b/main/jpp_settings_screen.c index 06e0e9f..e67bdbe 100644 --- a/main/jpp_settings_screen.c +++ b/main/jpp_settings_screen.c @@ -123,7 +123,7 @@ static size_t volume_step_index(uint8_t pct) static const char *SECTION_NAMES[JPP_SETTINGS_SECTION_COUNT] = { "Shutdown/Reboot", "Wi-Fi", "Time", "Sleep timers", - "Sound", "SD card", "Backup settings", "Factory Reset", + "Sound", "Controls", "SD card", "Backup settings", "Factory Reset", "* Device Info *", "User's name", "Dummy Mode", "About", }; @@ -452,6 +452,14 @@ static void render_sound(const jpp_settings_state_t *state) ssd1306_draw_string(7, 0, "OK on Test: play", false); } +static void render_controls(const jpp_settings_state_t *state) +{ + draw_section_heading("Controls"); + const char *back_label = state->back_gesture_mode ? "2x Click" : "Hold"; + draw_list_item_kv(2, true, "Back", back_label); + ssd1306_draw_string(6, 0, "L/R: change", false); +} + /* ---- Shutdown/Reboot 32×32 icons --------------------------------------- */ static const uint8_t ICON_REBOOT_FOCUS[128] = { @@ -882,6 +890,7 @@ void jpp_settings_screen_render(jpp_settings_state_t *state, case JPP_SETTINGS_SECTION_TIME: render_time(state, deps); break; case JPP_SETTINGS_SECTION_SLEEP_TIMERS: render_sleep_timers_section(state, deps); break; case JPP_SETTINGS_SECTION_SOUND: render_sound(state); break; + case JPP_SETTINGS_SECTION_CONTROLS: render_controls(state); break; case JPP_SETTINGS_SECTION_SD_CARD: render_storage(deps); break; case JPP_SETTINGS_SECTION_BACKUP: render_backup_settings(state); break; case JPP_SETTINGS_SECTION_FACTORY_RESET: render_factory_reset(state); break; @@ -1356,6 +1365,24 @@ static bool handle_sound(jpp_settings_state_t *state, return false; } +static bool handle_controls(jpp_settings_state_t *state, + const jpp_settings_deps_t *deps, + jpp_ui_action_t action) +{ + switch (action) { + case JPP_UI_ACTION_LEFT: + case JPP_UI_ACTION_RIGHT: + state->back_gesture_mode = state->back_gesture_mode ? 0u : 1u; + if (deps->do_back_gesture_change) { deps->do_back_gesture_change(state->back_gesture_mode); } + jpp_buzzer_play(JPP_BUZZER_SOUND_CLICK); + break; + case JPP_UI_ACTION_BACK: + return true; + default: break; + } + return false; +} + static bool handle_username(jpp_settings_state_t *state, const jpp_settings_deps_t *deps, jpp_ui_action_t action) @@ -1438,6 +1465,8 @@ bool jpp_settings_screen_handle_action(jpp_settings_state_t *state, close_section = handle_sleep_timers(state, deps, action); break; case JPP_SETTINGS_SECTION_SOUND: close_section = handle_sound(state, deps, action); break; + case JPP_SETTINGS_SECTION_CONTROLS: + close_section = handle_controls(state, deps, action); break; case JPP_SETTINGS_SECTION_SD_CARD: close_section = (action == JPP_UI_ACTION_BACK); break; case JPP_SETTINGS_SECTION_BACKUP: diff --git a/main/jpp_settings_screen.h b/main/jpp_settings_screen.h index 37b10ee..a3c7b8e 100644 --- a/main/jpp_settings_screen.h +++ b/main/jpp_settings_screen.h @@ -31,6 +31,7 @@ typedef enum { JPP_SETTINGS_SECTION_TIME, JPP_SETTINGS_SECTION_SLEEP_TIMERS, JPP_SETTINGS_SECTION_SOUND, /* buzzer volume, startup jingle, test */ + JPP_SETTINGS_SECTION_CONTROLS, /* back button gesture: Hold / Double-click */ JPP_SETTINGS_SECTION_SD_CARD, JPP_SETTINGS_SECTION_BACKUP, JPP_SETTINGS_SECTION_FACTORY_RESET, @@ -122,6 +123,9 @@ typedef struct { uint8_t sound_volume_pct; /* active volume: 0 / 25 / 50 / 75 / 100 */ uint8_t sound_jingle; /* selected startup jingle (jpp_startup_jingle_t) */ + /* Controls section */ + uint8_t back_gesture_mode; /* jpp_keypad_back_gesture_t: 0=Hold, 1=Double-click */ + /* Device Info / LRV section */ bool lrv_has_data; jpp_lrv_subscreen_t lrv_ss; @@ -172,6 +176,9 @@ typedef struct { /* Called when the user changes the startup jingle. Persists to NVS. The new jingle is played immediately as a preview. */ void (*do_jingle_change)(uint8_t jingle); + /* Called when the user changes the Back button gesture (Hold/Double-click + in the Controls section). Applies live and persists to NVS. */ + void (*do_back_gesture_change)(uint8_t mode); /* Backup all settings (settings.json + NVS) to the SD card. Writes a human-readable result into state->backup_result_msg on return. */ From fab297f0eb46b38469ff0ee07ad6267dc4fca9d2 Mon Sep 17 00:00:00 2001 From: Georgiy Panin Date: Mon, 27 Jul 2026 20:33:44 +0700 Subject: [PATCH 2/5] Expose back-gesture control to MicroPython apps set_back_gesture_enabled and set_force_hold_back_gesture were native-C only; add the jppsdk bindings (qstr + module table entries) so MicroPython apps get the same control, and update the docs and testapp_mp accordingly. --- AGENTS.md | 2 +- apps/testapp_mp/main.py | 21 +++++++++++++++-- components/jpp_core/src/jpp_mp_sdk_module.c | 26 +++++++++++++++++++++ components/micropython/qstrdefsport.h | 2 ++ docs/sdk-reference.md | 10 ++++++-- 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2cb601a..090474b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ jppdos/ | `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, so re-enable it after a dialog/list. `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`. Ungated surface: storage, KV, IPC, device status (now includes `username`), get_time, is_dummy_mode, UI/buzzer/LED/wakelock/canvas/module-load. `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)` (native only) 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_set_back_gesture_enabled(ctx, enabled)` (native only, ungated, defaults to `true` on every bind) lets an app drop the CENTER "Back" gesture (long-press or double-click, per Settings > Controls) instead of receiving it as `JPP_SDK_KEY_CENTER_LONG` — for apps that overload CENTER as a primary action button, so an accidental hold/double-tap mid-action doesn't yank the player into a pause/exit screen; the app re-enables it before/while showing its own pause or exit confirmation. Implemented as a plain `bool` on `jpp_sdk_context_t`, checked in `keypad_task` (`main/app_main.c`) before forwarding the gesture — does not touch UP/DOWN/LEFT/RIGHT/OK and has no effect on launcher/Settings navigation. `jpp_sdk_set_force_hold_back_gesture(ctx, force)` (native only, ungated, defaults to `false`) goes further: while forced, `keypad_task` builds the shared `jpp_keypad_config_t` passed to `jpp_keypad_poll()` with `back_gesture` pinned to `JPP_KEYPAD_BACK_GESTURE_HOLD` for as long as this app is foregrounded, overriding the system-wide Settings > Controls preference (Hold vs Double-click) at the physical-detector level rather than just at delivery — so a Double-click-mode user still gets instant "OK"/fire (no double-click defer) and only a deliberate hold (never a double-tap) reaches this app as `JPP_SDK_KEY_CENTER_LONG`. The launcher and every other app keep using the system preference unaffected; it reverts automatically when the app exits. DOOM-lite (see BUILTIN APPS) is the reference user for both calls. | +| `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, so re-enable it after a dialog/list. `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`. Ungated surface: storage, KV, IPC, device status (now includes `username`), get_time, is_dummy_mode, UI/buzzer/LED/wakelock/canvas/module-load. `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)` (native only) 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_set_back_gesture_enabled(ctx, enabled)` (ungated, exposed to both native and MicroPython apps, defaults to `true` on every bind) lets an app drop the CENTER "Back" gesture (long-press or double-click, per Settings > Controls) instead of receiving it as `JPP_SDK_KEY_CENTER_LONG` — for apps that overload CENTER as a primary action button, so an accidental hold/double-tap mid-action doesn't yank the player into a pause/exit screen; the app re-enables it before/while showing its own pause or exit confirmation. Implemented as a plain `bool` on `jpp_sdk_context_t`, checked in `keypad_task` (`main/app_main.c`) before forwarding the gesture — does not touch UP/DOWN/LEFT/RIGHT/OK and has no effect on launcher/Settings navigation. `jpp_sdk_set_force_hold_back_gesture(ctx, force)` (ungated, exposed to both native and MicroPython apps, defaults to `false`) goes further: while forced, `keypad_task` builds the shared `jpp_keypad_config_t` passed to `jpp_keypad_poll()` with `back_gesture` pinned to `JPP_KEYPAD_BACK_GESTURE_HOLD` for as long as this app is foregrounded, overriding the system-wide Settings > Controls preference (Hold vs Double-click) at the physical-detector level rather than just at delivery — so a Double-click-mode user still gets instant "OK"/fire (no double-click defer) and only a deliberate hold (never a double-tap) reaches this app as `JPP_SDK_KEY_CENTER_LONG`. The launcher and every other app keep using the system preference unaffected; it reverts automatically when the app exits. DOOM-lite (see BUILTIN APPS) is the reference user for both calls. | | `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`, 64 KB) for the running app: executable code for native apps **or** the MicroPython GC heap. `jpp_app_pool_acquire()`/`_release()`/`_in_use()`. Native and MP apps are mutually exclusive, so one pool serves both; sharing one pool instead of reserving separate exec + GC pools keeps the static footprint minimal. 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 | diff --git a/apps/testapp_mp/main.py b/apps/testapp_mp/main.py index 377cebb..a3b4467 100644 --- a/apps/testapp_mp/main.py +++ b/apps/testapp_mp/main.py @@ -588,8 +588,23 @@ def test_background_register(sdk): _show_result(sdk, "background_register", False, str(e)) +def test_back_gesture(sdk): + try: + sdk.set_back_gesture_enabled(False) + _show_result(sdk, "set_back_gesture_enabled", True, "disabled") + sdk.set_back_gesture_enabled(True) + _show_result(sdk, "set_back_gesture_enabled", True, "re-enabled") + sdk.set_force_hold_back_gesture(True) + _show_result(sdk, "set_force_hold_back_gesture", True, "forced") + sdk.set_force_hold_back_gesture(False) + _show_result(sdk, "set_force_hold_back_gesture", True, "released") + except jppsdk.SdkError as e: + _show_result(sdk, "back_gesture", False, str(e)) + + def menu_system(sdk): - items = ["Wakelock acq/rel", "Log event", "Background register"] + items = ["Wakelock acq/rel", "Log event", "Background register", + "Back gesture toggle"] while True: sel = _pick(sdk, "System Tests", items) if sel is None: @@ -598,8 +613,10 @@ def menu_system(sdk): test_wakelock(sdk) elif sel == 1: test_log(sdk) - else: + elif sel == 2: test_background_register(sdk) + else: + test_back_gesture(sdk) # --------------------------------------------------------------------------- # diff --git a/components/jpp_core/src/jpp_mp_sdk_module.c b/components/jpp_core/src/jpp_mp_sdk_module.c index 697623f..7ec9b1a 100644 --- a/components/jpp_core/src/jpp_mp_sdk_module.c +++ b/components/jpp_core/src/jpp_mp_sdk_module.c @@ -364,6 +364,30 @@ STATIC mp_obj_t mp_sdk_wait_key(mp_obj_t timeout_obj) } STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_sdk_wait_key_obj, mp_sdk_wait_key); +/* set_back_gesture_enabled(enabled) → None */ +STATIC mp_obj_t mp_sdk_set_back_gesture_enabled(mp_obj_t enabled_obj) +{ + bool enabled = mp_obj_is_true(enabled_obj); + jpp_sdk_status_t st = jpp_sdk_set_back_gesture_enabled(get_ctx(), enabled); + if (st != JPP_SDK_STATUS_OK) { + raise_sdk_error(st, NULL); + } + return mp_const_none; +} +STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_sdk_set_back_gesture_enabled_obj, mp_sdk_set_back_gesture_enabled); + +/* set_force_hold_back_gesture(force) → None */ +STATIC mp_obj_t mp_sdk_set_force_hold_back_gesture(mp_obj_t force_obj) +{ + bool force = mp_obj_is_true(force_obj); + jpp_sdk_status_t st = jpp_sdk_set_force_hold_back_gesture(get_ctx(), force); + if (st != JPP_SDK_STATUS_OK) { + raise_sdk_error(st, NULL); + } + return mp_const_none; +} +STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_sdk_set_force_hold_back_gesture_obj, mp_sdk_set_force_hold_back_gesture); + /* -------------------------------------------------------------------------- */ /* BLE — scan (requires: ble.scan) */ /* -------------------------------------------------------------------------- */ @@ -1127,6 +1151,8 @@ STATIC const mp_rom_map_elem_t jppsdk_module_globals_table[] = { /* Input */ { MP_ROM_QSTR(MP_QSTR_poll_key), MP_ROM_PTR(&mp_sdk_poll_key_obj) }, { MP_ROM_QSTR(MP_QSTR_wait_key), MP_ROM_PTR(&mp_sdk_wait_key_obj) }, + { MP_ROM_QSTR(MP_QSTR_set_back_gesture_enabled), MP_ROM_PTR(&mp_sdk_set_back_gesture_enabled_obj) }, + { MP_ROM_QSTR(MP_QSTR_set_force_hold_back_gesture), MP_ROM_PTR(&mp_sdk_set_force_hold_back_gesture_obj) }, /* High-level UI */ { MP_ROM_QSTR(MP_QSTR_dialog), MP_ROM_PTR(&mp_sdk_dialog_obj) }, diff --git a/components/micropython/qstrdefsport.h b/components/micropython/qstrdefsport.h index de14915..b92b38e 100644 --- a/components/micropython/qstrdefsport.h +++ b/components/micropython/qstrdefsport.h @@ -31,6 +31,8 @@ Q(log) Q(device_status) Q(get_time) Q(is_dummy_mode) +Q(set_back_gesture_enabled) +Q(set_force_hold_back_gesture) Q(file_read) Q(file_write) Q(file_list) diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index 1b31fe3..c8558a3 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -267,7 +267,7 @@ void jpp_sdk_push_key(jpp_sdk_context_t *ctx, jpp_sdk_key_event_t event); --- -### `set_back_gesture_enabled` (C only) +### `set_back_gesture_enabled` Enable or disable delivery of the "Back" gesture to your app. @@ -276,6 +276,9 @@ Enable or disable delivery of the "Back" gesture to your app. ```c jpp_sdk_status_t jpp_sdk_set_back_gesture_enabled(jpp_sdk_context_t *ctx, bool enabled); ``` +```python +jppsdk.set_back_gesture_enabled(enabled: bool) -> None +``` **Parameters:** @@ -287,7 +290,7 @@ jpp_sdk_status_t jpp_sdk_set_back_gesture_enabled(jpp_sdk_context_t *ctx, bool e --- -### `set_force_hold_back_gesture` (C only) +### `set_force_hold_back_gesture` Force CENTER's "Back" trigger to always be a hold for your app, regardless of the system-wide Settings > Controls preference. @@ -296,6 +299,9 @@ Force CENTER's "Back" trigger to always be a hold for your app, regardless of th ```c jpp_sdk_status_t jpp_sdk_set_force_hold_back_gesture(jpp_sdk_context_t *ctx, bool force); ``` +```python +jppsdk.set_force_hold_back_gesture(force: bool) -> None +``` **Parameters:** From 661a9da4b80b6952a29aaf3a454bc77f1b29fa67 Mon Sep 17 00:00:00 2001 From: Evgeny Malevich Date: Wed, 29 Jul 2026 00:04:45 +0300 Subject: [PATCH 3/5] Resolve CENTER gestures by app intent instead of by SDK toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on NovaStream2030's double-click Back gesture, keeping the feature and the Settings > Controls preference intact while moving where the decision is made. jpp_keypad_core goes back to being a pure detector: it no longer knows what "Back" is. It always reports CENTER_SHORT / CENTER_LONG / CENTER_DOUBLE and carries a single timing knob, detect_double_click, replacing the back_gesture policy enum in jpp_keypad_config_t. Two consequences fall out: a hold is now detected in both modes (previously selecting Double-click meant a hold produced no event at all), and a short click still in flight when the mode changes is flushed rather than stranded or replayed. keypad_task in main/app_main.c becomes the one place that knows both the user's preference and what the foreground app wants, and turns raw gestures into a Back action, a raw app key, or nothing. On the SDK side, jpp_sdk_set_back_gesture_enabled and jpp_sdk_set_force_hold_back_gesture are replaced by a single jpp_sdk_claim_center(ctx, mask). Both setters described a mechanism the app had to reason about — one suppressed delivery, the other overrode the detector — and neither let an app write code that was blind to the user's setting. A claim describes intent instead: claim nothing -> JPP_SDK_KEY_CENTER + JPP_SDK_KEY_BACK, with the firmware deciding which gesture is Back claim anything -> the claimed gestures arrive as CENTER_HOLD/_DOUBLE, BACK stops being delivered, and the app owns its exit Claiming only HOLD also keeps the short click instant, because nothing then needs to tell a double-click apart — which is what the force-hold setter existed to achieve, now as a consequence rather than a flag. JPP_SDK_KEY_BACK is an alias of the existing JPP_SDK_KEY_CENTER_LONG, so apps and already-built binaries are unaffected. The new key values and the center_claim field are appended, never inserted: native apps are separately-built ELFs loaded from SD and read jpp_sdk_context_t fields directly (apps/demoscene and apps/games both read canvas_fullscreen), so inserting into the struct would silently shift every offset they were compiled against. Also keeps NovaStream2030's jpp_sdk_confirm symtab fix, which is unrelated to this feature but a real one: without it any native app calling jpp_sdk_confirm dies at launch with UNRESOLVED_SYM. --- components/jpp_core/include/jpp_keypad_core.h | 27 ++- components/jpp_core/include/jpp_sdk_bridge.h | 69 +++++--- components/jpp_core/include/jpp_ui_core.h | 3 + components/jpp_core/src/jpp_keypad_core.c | 140 ++++++++------- components/jpp_core/src/jpp_mp_sdk_module.c | 32 ++-- components/jpp_core/src/jpp_sdk_bridge.c | 18 +- components/jpp_core/src/jpp_ui_core.c | 8 +- .../src/jpp_native_symtab.c | 3 +- components/micropython/qstrdefsport.h | 9 +- main/app_main.c | 166 ++++++++++++------ 10 files changed, 291 insertions(+), 184 deletions(-) diff --git a/components/jpp_core/include/jpp_keypad_core.h b/components/jpp_core/include/jpp_keypad_core.h index 1c49fe1..e3e109a 100644 --- a/components/jpp_core/include/jpp_keypad_core.h +++ b/components/jpp_core/include/jpp_keypad_core.h @@ -16,9 +16,11 @@ extern "C" { #define JPP_KEYPAD_DEFAULT_REPEAT_INTERVAL_MS 500 #define JPP_KEYPAD_DEFAULT_DOUBLE_CLICK_MS 300 -/* Selects how CENTER triggers "Back": holding for long_press_ms (default, - zero-latency OK), or two short clicks within double_click_ms (defers OK - until the window passes with no second click). Mutually exclusive. */ +/* Which physical CENTER gesture the user has chosen to mean "Back". + This is a *policy* type: the detector below never reads it. It is owned by + the settings layer and resolved into a Back action by keypad_task in + main/app_main.c, which is the only place that knows both the user + preference and what the foreground app has claimed. */ typedef enum { JPP_KEYPAD_BACK_GESTURE_HOLD = 0, JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK, @@ -51,7 +53,12 @@ typedef struct { bool repeat_enabled; int repeat_delay_ms; int repeat_interval_ms; - jpp_keypad_back_gesture_t back_gesture; + /* When set, a short CENTER click is withheld for double_click_ms so a + second click can be reported as CENTER_DOUBLE instead. When clear, + CENTER_SHORT is emitted the moment the button is released and + CENTER_DOUBLE never fires. This is the only latency knob: pay for + double-click discrimination exactly when something needs it. */ + bool detect_double_click; int double_click_ms; const jpp_keypad_band_t *bands; size_t band_count; @@ -82,11 +89,13 @@ typedef struct { int last_repeat_ms; bool center_long_emitted; size_t sample_index; - /* Double-click back-gesture: a deferred short click waiting to see if a - second click follows within double_click_ms. Survives across the idle - gap between two separate press/release cycles, unlike press_started_ms - above which jpp_keypad_reset_hold_state() clears on every release. */ - bool ok_pending; + /* A deferred short click waiting to see if a second click follows within + double_click_ms. Survives across the idle gap between two separate + press/release cycles, unlike press_started_ms above which + jpp_keypad_reset_hold_state() clears on every release. Flushed + immediately if detect_double_click is cleared while one is in flight, + so a mode change can never strand or replay a click. */ + bool short_pending; int pending_release_ms; } jpp_keypad_state_t; diff --git a/components/jpp_core/include/jpp_sdk_bridge.h b/components/jpp_core/include/jpp_sdk_bridge.h index e61700c..1c2bdb8 100644 --- a/components/jpp_core/include/jpp_sdk_bridge.h +++ b/components/jpp_core/include/jpp_sdk_bridge.h @@ -465,8 +465,24 @@ typedef enum { JPP_SDK_KEY_RIGHT, JPP_SDK_KEY_CENTER, JPP_SDK_KEY_CENTER_LONG, + /* Raw CENTER gestures, delivered only to apps that claimed them via + jpp_sdk_claim_center(). Appended deliberately: native apps are + separately-built ELFs, so these values must never be renumbered. */ + JPP_SDK_KEY_CENTER_HOLD, + JPP_SDK_KEY_CENTER_DOUBLE, + /* Preferred spelling of the semantic "user wants to go back" event. Same + value as JPP_SDK_KEY_CENTER_LONG, which predates the Settings > Controls + preference and is kept so existing apps and binaries are unaffected. */ + JPP_SDK_KEY_BACK = JPP_SDK_KEY_CENTER_LONG, } jpp_sdk_key_event_t; +/* Bits for jpp_sdk_claim_center(). Claiming a gesture takes it over as your + own input; claiming anything at all means JPP_SDK_KEY_BACK is no longer + delivered and your app owns its exit. */ +#define JPP_SDK_CENTER_CLAIM_NONE 0x00u +#define JPP_SDK_CENTER_CLAIM_HOLD 0x01u +#define JPP_SDK_CENTER_CLAIM_DOUBLE 0x02u + /* ---- High-level UI abstractions ----------------------------------------- */ /* * Dialog, List, and Input are blocking, modal helpers built on the frame and @@ -516,8 +532,6 @@ typedef struct { bool bound; bool wakelock_held; /* prevents screen dim / deep sleep when true */ bool dummy_mode; /* set when the app is launched as the dummy-mode locked app */ - bool back_gesture_enabled; /* true by default; see jpp_sdk_set_back_gesture_enabled() */ - bool force_hold_back_gesture; /* false by default; see jpp_sdk_set_force_hold_back_gesture() */ QueueHandle_t key_queue; /* FreeRTOS queue, capacity 8, element jpp_sdk_key_event_t */ /* Pixel canvas — row-major, MSB = leftmost pixel. Rows 0–47 in windowed mode (pages 2–7); all 64 rows when canvas_fullscreen is set. */ @@ -531,6 +545,11 @@ typedef struct { char pending_cap_strs[JPP_SDK_PENDING_CAP_MAX][32]; int pending_cap_tiers[JPP_SDK_PENDING_CAP_MAX]; size_t pending_cap_count; + /* CENTER gestures this app has taken over; see jpp_sdk_claim_center(). + New fields go here, at the tail: app binaries are built separately and + loaded from SD, so inserting above this point shifts every offset they + were compiled against. */ + uint8_t center_claim; } jpp_sdk_context_t; void jpp_sdk_context_init(jpp_sdk_context_t *context); @@ -975,28 +994,30 @@ jpp_sdk_status_t jpp_sdk_wait_key(jpp_sdk_context_t *context, uint32_t timeout_m /* Called by the main loop to push a key event into the active app's queue. */ void jpp_sdk_push_key(jpp_sdk_context_t *context, jpp_sdk_key_event_t event); -/* Ungated — when enabled (the default), a CENTER long-press or double-click - (whichever gesture Settings > Controls has selected) is delivered to the - app as JPP_SDK_KEY_CENTER_LONG. When disabled, that gesture is dropped - silently: the app receives nothing for it. Use this to stop an accidental - long hold/double-click on a primary action button (e.g. a shoot button) - from being misread as "Back" during fast gameplay — disable while the - button means something else, re-enable before/while showing your own - pause or exit-confirmation screen. Does not affect UP/DOWN/LEFT/RIGHT/OK, - and has no effect on the launcher/Settings navigation outside your app. */ -jpp_sdk_status_t jpp_sdk_set_back_gesture_enabled(jpp_sdk_context_t *context, bool enabled); - -/* Ungated — while forced (the default is off), CENTER's "Back" trigger is - always evaluated as a hold (JPP_KEYPAD_BACK_GESTURE_HOLD) for this app's - key stream, regardless of the system-wide Settings > Controls preference - (Hold vs Double-click), which keeps governing the launcher and every other - app. Use this when your app overloads CENTER as a primary action button - (e.g. "fire"): it removes the double-click window's inherent delay on the - short-click ("OK"/fire) delivery, and guarantees a long hold — never a - double-tap — is what reaches you as JPP_SDK_KEY_CENTER_LONG. Reverts to - the system preference automatically when your app exits. Has no effect - outside your app. */ -jpp_sdk_status_t jpp_sdk_set_force_hold_back_gesture(jpp_sdk_context_t *context, bool force); +/* + * Ungated — take over CENTER gestures as your own input. + * + * `mask` is a bitwise OR of JPP_SDK_CENTER_CLAIM_HOLD / _DOUBLE, or + * JPP_SDK_CENTER_CLAIM_NONE (the default on every bind) to leave CENTER + * alone. The rule is: + * + * claim nothing → you get JPP_SDK_KEY_CENTER and JPP_SDK_KEY_BACK. + * The firmware decides which physical gesture means Back + * from the user's Settings > Controls preference; your + * code never sees that choice. + * claim anything → the claimed gestures arrive as JPP_SDK_KEY_CENTER_HOLD + * / _DOUBLE, JPP_SDK_KEY_BACK is no longer delivered, and + * your app is responsible for its own way out. + * + * Claiming only HOLD also keeps JPP_SDK_KEY_CENTER instant: nothing then + * needs to tell a double-click apart, so the short click is never withheld + * to wait for a second one. That is the combination to use for an app where + * CENTER is a rapid action button ("fire") and hold opens a pause menu. + * + * Never affects UP/DOWN/LEFT/RIGHT, and never affects the launcher or + * Settings — the claim lives on your context and is dropped when you exit. + */ +jpp_sdk_status_t jpp_sdk_claim_center(jpp_sdk_context_t *context, uint8_t mask); /* ---- High-level UI helpers (no capability required) ---------------------- */ diff --git a/components/jpp_core/include/jpp_ui_core.h b/components/jpp_core/include/jpp_ui_core.h index ec7af70..21ea299 100644 --- a/components/jpp_core/include/jpp_ui_core.h +++ b/components/jpp_core/include/jpp_ui_core.h @@ -128,6 +128,9 @@ jpp_ui_status_t jpp_ui_display_render_lines( jpp_ui_frame_t *rendered_frame, bool *changed ); +/* Maps a keypad event to a UI action. CENTER hold/double-click gestures are + NOT handled here — they are policy (see the note in the implementation) and + are resolved by keypad_task() before this is called. */ jpp_ui_action_t jpp_ui_normalize_action(const jpp_keypad_event_t *event); const char *jpp_ui_action_name(jpp_ui_action_t action); const char *jpp_ui_status_name(jpp_ui_status_t status); diff --git a/components/jpp_core/src/jpp_keypad_core.c b/components/jpp_core/src/jpp_keypad_core.c index 100c51d..9f8a1fb 100644 --- a/components/jpp_core/src/jpp_keypad_core.c +++ b/components/jpp_core/src/jpp_keypad_core.c @@ -68,12 +68,12 @@ static int jpp_keypad_config_repeat_interval_ms(const jpp_keypad_config_t *confi return config->repeat_interval_ms; } -static jpp_keypad_back_gesture_t jpp_keypad_config_back_gesture(const jpp_keypad_config_t *config) +static bool jpp_keypad_config_detect_double_click(const jpp_keypad_config_t *config) { if (config == NULL) { - return JPP_KEYPAD_BACK_GESTURE_HOLD; + return false; } - return config->back_gesture; + return config->detect_double_click; } static int jpp_keypad_config_double_click_ms(const jpp_keypad_config_t *config) @@ -84,11 +84,17 @@ static int jpp_keypad_config_double_click_ms(const jpp_keypad_config_t *config) return config->double_click_ms; } -/* Emits the deferred OK for a short click once double_click_ms has passed - with no second click. Called once per poll, before any other event logic, - so a click that finally times out resolves before a later click in the - same poll can start a fresh pending cycle. */ -static int jpp_keypad_check_pending_ok( +/* Releases a deferred short click once double_click_ms has passed with no + second click. Called once per poll, before any other event logic, so a + click that finally times out resolves before a later click in the same + poll can start a fresh pending cycle. + + Also flushes immediately when double-click detection has been switched off + underneath a click that is still in flight — the mode can change between + polls (the user toggling Settings > Controls, or an app being foregrounded + that claims CENTER differently), and neither dropping the click nor + replaying it later would be correct. */ +static int jpp_keypad_check_pending_short( jpp_keypad_state_t *state, const jpp_keypad_config_t *config, int now_ms, @@ -97,16 +103,17 @@ static int jpp_keypad_check_pending_ok( size_t *event_count ) { - if (state == NULL || !state->ok_pending) { + if (state == NULL || !state->short_pending) { return 0; } - if (now_ms - state->pending_release_ms < jpp_keypad_config_double_click_ms(config)) { + if (jpp_keypad_config_detect_double_click(config) && + now_ms - state->pending_release_ms < jpp_keypad_config_double_click_ms(config)) { return 0; } if (*event_count >= event_capacity) { return -1; } - state->ok_pending = false; + state->short_pending = false; events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_SHORT; events[*event_count].key = "CENTER"; events[*event_count].mapped = "OK"; @@ -253,46 +260,50 @@ static int jpp_keypad_finalize_release( duration_ms = 0; } if (strcmp(previous_key, "CENTER") == 0) { - if (jpp_keypad_config_back_gesture(config) == JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK) { - /* Long hold: the live branch in jpp_keypad_poll() never fires - CENTER_LONG in this mode, so there is nothing to finalize. */ - if (duration_ms < jpp_keypad_config_long_press_ms(config)) { - if (state->ok_pending && - now_ms - state->pending_release_ms <= jpp_keypad_config_double_click_ms(config)) { - if (*event_count >= event_capacity) { - return -1; - } - state->ok_pending = false; - events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_DOUBLE; - events[*event_count].key = previous_key; - events[*event_count].mapped = "BACK"; - events[*event_count].duration_ms = duration_ms; - *event_count += 1u; - } else { - state->ok_pending = true; - state->pending_release_ms = now_ms; + if (duration_ms >= jpp_keypad_config_long_press_ms(config)) { + /* A hold. jpp_keypad_poll() normally emits CENTER_LONG live the + moment the threshold is crossed; finalize only has work to do + when the release landed in the same poll as the crossing. */ + if (!state->center_long_emitted) { + if (*event_count >= event_capacity) { + return -1; } + events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_LONG; + events[*event_count].key = previous_key; + events[*event_count].mapped = "HOLD"; + events[*event_count].duration_ms = duration_ms; + *event_count += 1u; } return 0; } - if (duration_ms >= jpp_keypad_config_long_press_ms(config) && !state->center_long_emitted) { + /* A short click. Report it straight away unless someone needs to be + able to tell a double-click apart, in which case hold it back until + the window closes (jpp_keypad_check_pending_short). */ + if (!jpp_keypad_config_detect_double_click(config)) { if (*event_count >= event_capacity) { return -1; } - events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_LONG; + events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_SHORT; events[*event_count].key = previous_key; - events[*event_count].mapped = "BACK"; + events[*event_count].mapped = "OK"; events[*event_count].duration_ms = duration_ms; *event_count += 1u; - } else if (duration_ms < jpp_keypad_config_long_press_ms(config)) { + return 0; + } + if (state->short_pending && + now_ms - state->pending_release_ms <= jpp_keypad_config_double_click_ms(config)) { if (*event_count >= event_capacity) { return -1; } - events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_SHORT; + state->short_pending = false; + events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_DOUBLE; events[*event_count].key = previous_key; - events[*event_count].mapped = "OK"; + events[*event_count].mapped = "DOUBLE"; events[*event_count].duration_ms = duration_ms; *event_count += 1u; + } else { + state->short_pending = true; + state->pending_release_ms = now_ms; } return 0; } @@ -334,7 +345,7 @@ void jpp_keypad_state_init(jpp_keypad_state_t *state, const jpp_keypad_config_t state->repeat_interval_ms = config == NULL ? 150 : config->repeat_interval_ms; state->press_started_ms = -1; state->last_repeat_ms = -1; - state->ok_pending = false; + state->short_pending = false; state->pending_release_ms = -1; state->ready = state->enabled; } @@ -384,7 +395,7 @@ int jpp_keypad_poll( return 0; } now_ms = (int)state->sample_index * jpp_keypad_config_poll_interval_ms(config); - if (jpp_keypad_check_pending_ok(state, config, now_ms, events, event_capacity, event_count) != 0) { + if (jpp_keypad_check_pending_short(state, config, now_ms, events, event_capacity, event_count) != 0) { return -1; } if (!sample_present) { @@ -410,35 +421,34 @@ int jpp_keypad_poll( state->press_started_ms = now_ms; } if (strcmp(candidate_key, "CENTER") == 0) { - /* Double-click mode has no live-hold gesture: CENTER_DOUBLE - is decided on release in jpp_keypad_finalize_release(). */ - if (jpp_keypad_config_back_gesture(config) == JPP_KEYPAD_BACK_GESTURE_HOLD) { - int duration_ms = now_ms - state->press_started_ms; - if (duration_ms >= jpp_keypad_config_long_press_ms(config) && !state->center_long_emitted) { - if (*event_count >= event_capacity) { - return -1; - } - state->center_long_emitted = true; - events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_LONG; - events[*event_count].key = candidate_key; - events[*event_count].mapped = "BACK"; - events[*event_count].duration_ms = duration_ms; - *event_count += 1u; - } else if (state->center_long_emitted && - now_ms - state->press_started_ms >= - jpp_keypad_config_long_press_ms(config) + - jpp_keypad_config_repeat_delay_ms(config) && - now_ms - state->last_repeat_ms >= jpp_keypad_config_repeat_interval_ms(config)) { - if (*event_count >= event_capacity) { - return -1; - } - state->last_repeat_ms = now_ms; - events[*event_count].kind = JPP_KEYPAD_KIND_REPEAT; - events[*event_count].key = candidate_key; - events[*event_count].mapped = "BACK"; - events[*event_count].duration_ms = 0; - *event_count += 1u; + /* A hold is always detected, whatever it ends up meaning — + the policy layer decides whether CENTER_LONG is "Back", + an app's own gesture, or nothing at all. */ + int duration_ms = now_ms - state->press_started_ms; + if (duration_ms >= jpp_keypad_config_long_press_ms(config) && !state->center_long_emitted) { + if (*event_count >= event_capacity) { + return -1; + } + state->center_long_emitted = true; + events[*event_count].kind = JPP_KEYPAD_KIND_CENTER_LONG; + events[*event_count].key = candidate_key; + events[*event_count].mapped = "HOLD"; + events[*event_count].duration_ms = duration_ms; + *event_count += 1u; + } else if (state->center_long_emitted && + now_ms - state->press_started_ms >= + jpp_keypad_config_long_press_ms(config) + + jpp_keypad_config_repeat_delay_ms(config) && + now_ms - state->last_repeat_ms >= jpp_keypad_config_repeat_interval_ms(config)) { + if (*event_count >= event_capacity) { + return -1; } + state->last_repeat_ms = now_ms; + events[*event_count].kind = JPP_KEYPAD_KIND_REPEAT; + events[*event_count].key = candidate_key; + events[*event_count].mapped = "HOLD"; + events[*event_count].duration_ms = 0; + *event_count += 1u; } } else if (jpp_keypad_emit_repeat_if_needed(state, config, candidate_key, now_ms, events, event_capacity, event_count) != 0) { return -1; diff --git a/components/jpp_core/src/jpp_mp_sdk_module.c b/components/jpp_core/src/jpp_mp_sdk_module.c index 7ec9b1a..ad31aae 100644 --- a/components/jpp_core/src/jpp_mp_sdk_module.c +++ b/components/jpp_core/src/jpp_mp_sdk_module.c @@ -364,29 +364,20 @@ STATIC mp_obj_t mp_sdk_wait_key(mp_obj_t timeout_obj) } STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_sdk_wait_key_obj, mp_sdk_wait_key); -/* set_back_gesture_enabled(enabled) → None */ -STATIC mp_obj_t mp_sdk_set_back_gesture_enabled(mp_obj_t enabled_obj) +/* claim_center(mask) → None */ +STATIC mp_obj_t mp_sdk_claim_center(mp_obj_t mask_obj) { - bool enabled = mp_obj_is_true(enabled_obj); - jpp_sdk_status_t st = jpp_sdk_set_back_gesture_enabled(get_ctx(), enabled); - if (st != JPP_SDK_STATUS_OK) { - raise_sdk_error(st, NULL); + mp_int_t mask = mp_obj_get_int(mask_obj); + if (mask < 0 || mask > 0xFF) { + raise_sdk_error(JPP_SDK_STATUS_INVALID_ARGUMENT, NULL); } - return mp_const_none; -} -STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_sdk_set_back_gesture_enabled_obj, mp_sdk_set_back_gesture_enabled); - -/* set_force_hold_back_gesture(force) → None */ -STATIC mp_obj_t mp_sdk_set_force_hold_back_gesture(mp_obj_t force_obj) -{ - bool force = mp_obj_is_true(force_obj); - jpp_sdk_status_t st = jpp_sdk_set_force_hold_back_gesture(get_ctx(), force); + jpp_sdk_status_t st = jpp_sdk_claim_center(get_ctx(), (uint8_t)mask); if (st != JPP_SDK_STATUS_OK) { raise_sdk_error(st, NULL); } return mp_const_none; } -STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_sdk_set_force_hold_back_gesture_obj, mp_sdk_set_force_hold_back_gesture); +STATIC MP_DEFINE_CONST_FUN_OBJ_1(mp_sdk_claim_center_obj, mp_sdk_claim_center); /* -------------------------------------------------------------------------- */ /* BLE — scan (requires: ble.scan) */ @@ -1120,6 +1111,12 @@ STATIC const mp_rom_map_elem_t jppsdk_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_KEY_RIGHT), MP_ROM_INT(JPP_SDK_KEY_RIGHT) }, { MP_ROM_QSTR(MP_QSTR_KEY_CENTER), MP_ROM_INT(JPP_SDK_KEY_CENTER) }, { MP_ROM_QSTR(MP_QSTR_KEY_CENTER_LONG), MP_ROM_INT(JPP_SDK_KEY_CENTER_LONG) }, + { MP_ROM_QSTR(MP_QSTR_KEY_BACK), MP_ROM_INT(JPP_SDK_KEY_BACK) }, + { MP_ROM_QSTR(MP_QSTR_KEY_CENTER_HOLD), MP_ROM_INT(JPP_SDK_KEY_CENTER_HOLD) }, + { MP_ROM_QSTR(MP_QSTR_KEY_CENTER_DOUBLE), MP_ROM_INT(JPP_SDK_KEY_CENTER_DOUBLE) }, + { MP_ROM_QSTR(MP_QSTR_CENTER_CLAIM_NONE), MP_ROM_INT(JPP_SDK_CENTER_CLAIM_NONE) }, + { MP_ROM_QSTR(MP_QSTR_CENTER_CLAIM_HOLD), MP_ROM_INT(JPP_SDK_CENTER_CLAIM_HOLD) }, + { MP_ROM_QSTR(MP_QSTR_CENTER_CLAIM_DOUBLE), MP_ROM_INT(JPP_SDK_CENTER_CLAIM_DOUBLE) }, /* Core */ { MP_ROM_QSTR(MP_QSTR_set_frame), MP_ROM_PTR(&mp_sdk_set_frame_obj) }, @@ -1151,8 +1148,7 @@ STATIC const mp_rom_map_elem_t jppsdk_module_globals_table[] = { /* Input */ { MP_ROM_QSTR(MP_QSTR_poll_key), MP_ROM_PTR(&mp_sdk_poll_key_obj) }, { MP_ROM_QSTR(MP_QSTR_wait_key), MP_ROM_PTR(&mp_sdk_wait_key_obj) }, - { MP_ROM_QSTR(MP_QSTR_set_back_gesture_enabled), MP_ROM_PTR(&mp_sdk_set_back_gesture_enabled_obj) }, - { MP_ROM_QSTR(MP_QSTR_set_force_hold_back_gesture), MP_ROM_PTR(&mp_sdk_set_force_hold_back_gesture_obj) }, + { MP_ROM_QSTR(MP_QSTR_claim_center), MP_ROM_PTR(&mp_sdk_claim_center_obj) }, /* High-level UI */ { MP_ROM_QSTR(MP_QSTR_dialog), MP_ROM_PTR(&mp_sdk_dialog_obj) }, diff --git a/components/jpp_core/src/jpp_sdk_bridge.c b/components/jpp_core/src/jpp_sdk_bridge.c index 63b8725..df3d2d7 100644 --- a/components/jpp_core/src/jpp_sdk_bridge.c +++ b/components/jpp_core/src/jpp_sdk_bridge.c @@ -334,8 +334,9 @@ void jpp_sdk_context_init(jpp_sdk_context_t *context) return; } memset(context, 0, sizeof(*context)); + /* memset leaves center_claim == JPP_SDK_CENTER_CLAIM_NONE, so every app + starts out with the system-managed Back gesture. */ context->key_queue = xQueueCreate(8u, sizeof(jpp_sdk_key_event_t)); - context->back_gesture_enabled = true; } jpp_sdk_status_t jpp_sdk_bind( @@ -2550,19 +2551,14 @@ void jpp_sdk_push_key(jpp_sdk_context_t *context, jpp_sdk_key_event_t event) xQueueSendToBack(context->key_queue, &event, 0); } -jpp_sdk_status_t jpp_sdk_set_back_gesture_enabled(jpp_sdk_context_t *context, bool enabled) +jpp_sdk_status_t jpp_sdk_claim_center(jpp_sdk_context_t *context, uint8_t mask) { jpp_sdk_status_t status = jpp_sdk_ensure_bound(context); if (status != JPP_SDK_STATUS_OK) { return status; } - context->back_gesture_enabled = enabled; - return JPP_SDK_STATUS_OK; -} - -jpp_sdk_status_t jpp_sdk_set_force_hold_back_gesture(jpp_sdk_context_t *context, bool force) -{ - jpp_sdk_status_t status = jpp_sdk_ensure_bound(context); - if (status != JPP_SDK_STATUS_OK) { return status; } - context->force_hold_back_gesture = force; + if ((mask & ~(JPP_SDK_CENTER_CLAIM_HOLD | JPP_SDK_CENTER_CLAIM_DOUBLE)) != 0u) { + return JPP_SDK_STATUS_INVALID_ARGUMENT; + } + context->center_claim = mask; return JPP_SDK_STATUS_OK; } diff --git a/components/jpp_core/src/jpp_ui_core.c b/components/jpp_core/src/jpp_ui_core.c index 261f2ce..0812f37 100644 --- a/components/jpp_core/src/jpp_ui_core.c +++ b/components/jpp_core/src/jpp_ui_core.c @@ -138,9 +138,11 @@ jpp_ui_action_t jpp_ui_normalize_action(const jpp_keypad_event_t *event) if (event->kind == JPP_KEYPAD_KIND_CENTER_SHORT || jpp_str_eq(event->mapped, "OK")) { return JPP_UI_ACTION_OK; } - if (event->kind == JPP_KEYPAD_KIND_CENTER_LONG || jpp_str_eq(event->mapped, "BACK")) { - return JPP_UI_ACTION_BACK; - } + /* CENTER_LONG / CENTER_DOUBLE are deliberately not mapped here: whether a + hold or a double-click means "Back" depends on the user's Settings > + Controls preference and on what the foreground app has claimed, and + neither is visible from a single keypad event. keypad_task() in + main/app_main.c resolves them before calling this. */ if ((event->kind == JPP_KEYPAD_KIND_PRESS || event->kind == JPP_KEYPAD_KIND_REPEAT) && jpp_ui_is_direction(event->key, &action)) { return action; diff --git a/components/jpp_native_loader_core/src/jpp_native_symtab.c b/components/jpp_native_loader_core/src/jpp_native_symtab.c index 2016023..aa3d8ea 100644 --- a/components/jpp_native_loader_core/src/jpp_native_symtab.c +++ b/components/jpp_native_loader_core/src/jpp_native_symtab.c @@ -71,8 +71,7 @@ static const jpp_native_sym_t s_symtab[] = { { "jpp_sdk_poll_key", (void *)jpp_sdk_poll_key }, { "jpp_sdk_wait_key", (void *)jpp_sdk_wait_key }, { "jpp_sdk_push_key", (void *)jpp_sdk_push_key }, - { "jpp_sdk_set_back_gesture_enabled", (void *)jpp_sdk_set_back_gesture_enabled }, - { "jpp_sdk_set_force_hold_back_gesture", (void *)jpp_sdk_set_force_hold_back_gesture }, + { "jpp_sdk_claim_center", (void *)jpp_sdk_claim_center }, /* Canvas */ { "jpp_sdk_canvas_clear", (void *)jpp_sdk_canvas_clear }, diff --git a/components/micropython/qstrdefsport.h b/components/micropython/qstrdefsport.h index b92b38e..1a7d700 100644 --- a/components/micropython/qstrdefsport.h +++ b/components/micropython/qstrdefsport.h @@ -31,8 +31,13 @@ Q(log) Q(device_status) Q(get_time) Q(is_dummy_mode) -Q(set_back_gesture_enabled) -Q(set_force_hold_back_gesture) +Q(claim_center) +Q(KEY_BACK) +Q(KEY_CENTER_HOLD) +Q(KEY_CENTER_DOUBLE) +Q(CENTER_CLAIM_NONE) +Q(CENTER_CLAIM_HOLD) +Q(CENTER_CLAIM_DOUBLE) Q(file_read) Q(file_write) Q(file_list) diff --git a/main/app_main.c b/main/app_main.c index 7db08d9..ce5418a 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -201,6 +201,94 @@ typedef struct { static keypad_task_ctx_t s_kpad_ctx; +/* Which CENTER gesture the user has picked to mean "Back" (Settings > + Controls, persisted in NVS jpp_input/back_gesture). Only ever consulted + here in the policy layer — neither the detector nor any app sees it. */ +static uint8_t s_back_gesture_mode = JPP_KEYPAD_BACK_GESTURE_HOLD; + +/* What the foreground app, if any, has taken over via jpp_sdk_claim_center(). */ +static uint8_t center_claim_now(void) +{ + if (s_active_sdk_context != NULL) { + return s_active_sdk_context->center_claim; + } + return JPP_SDK_CENTER_CLAIM_NONE; +} + +/* Double-click discrimination costs the short click a double_click_ms delay, + so ask for it only when something actually needs to tell the two apart: + an app that claimed the double-click, or — when nothing is claimed — a user + who chose it as their Back gesture. */ +static bool center_needs_double_click(void) +{ + uint8_t claim = center_claim_now(); + if (claim != JPP_SDK_CENTER_CLAIM_NONE) { + return (claim & JPP_SDK_CENTER_CLAIM_DOUBLE) != 0u; + } + return s_back_gesture_mode == JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK; +} + +/* Hand a key to the foreground app (and, for MicroPython apps, wake the VM). */ +static void keypad_push_app_key(jpp_sdk_key_event_t sdk_key) +{ + if (sdk_key == JPP_SDK_KEY_NONE || s_active_sdk_context == NULL) { + return; + } + jpp_sdk_push_key(s_active_sdk_context, sdk_key); + if (s_sd_task != NULL && s_sd_is_mp && s_active_sdk_context == &s_sd_ctx) { + jpp_vm_request_t act_req; + memset(&act_req, 0, sizeof(act_req)); + act_req.kind = JPP_VM_REQUEST_ACTION; + act_req.action_payload = (uint32_t)sdk_key; + strncpy(act_req.app_id, s_sd_ctx.app_id, sizeof(act_req.app_id) - 1u); + jpp_vm_schedule_request(&s_sd_vm, "keypad", &act_req); + } +} + +/* Resolves one raw CENTER gesture into whatever it means right now. Returns + true if the event was a CENTER gesture and has been fully handled. */ +static bool keypad_handle_center_gesture(const jpp_keypad_event_t *ev) +{ + bool is_hold = (ev->kind == JPP_KEYPAD_KIND_CENTER_LONG) || + (ev->kind == JPP_KEYPAD_KIND_REPEAT && + ev->key != NULL && strcmp(ev->key, "CENTER") == 0); + bool is_double = (ev->kind == JPP_KEYPAD_KIND_CENTER_DOUBLE); + if (!is_hold && !is_double) { + return false; + } + + uint8_t claim = center_claim_now(); + uint8_t bit = is_hold ? JPP_SDK_CENTER_CLAIM_HOLD : JPP_SDK_CENTER_CLAIM_DOUBLE; + + if ((claim & bit) != 0u) { + /* The app took this gesture over as its own input. The auto-repeat of + a hold is not forwarded: it exists so Back can pop several screens + at once, not to make an app's pause gesture fire over and over. */ + if (ev->kind != JPP_KEYPAD_KIND_REPEAT) { + keypad_push_app_key(is_hold ? JPP_SDK_KEY_CENTER_HOLD + : JPP_SDK_KEY_CENTER_DOUBLE); + } + return true; + } + if (claim != JPP_SDK_CENTER_CLAIM_NONE) { + /* Claiming anything means the app owns its own way out, so the + unclaimed gesture is not repurposed as Back behind its back. */ + return true; + } + + bool is_back = is_hold ? (s_back_gesture_mode == JPP_KEYPAD_BACK_GESTURE_HOLD) + : (s_back_gesture_mode == JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK); + if (!is_back) { + return true; + } + jpp_ui_action_t back = JPP_UI_ACTION_BACK; + xQueueSend(s_action_queue, &back, 0); + if (ev->kind != JPP_KEYPAD_KIND_REPEAT) { + keypad_push_app_key(JPP_SDK_KEY_BACK); + } + return true; +} + static void keypad_task(void *arg) { keypad_task_ctx_t *ctx = (keypad_task_ctx_t *)arg; @@ -226,12 +314,7 @@ static void keypad_task(void *arg) jpp_keypad_event_t events[8]; size_t event_count = 0u; jpp_keypad_config_t poll_cfg = ctx->cfg; - if (s_active_sdk_context != NULL && s_active_sdk_context->force_hold_back_gesture) { - /* The foreground app wants CENTER's Back trigger to always be a - hold, regardless of the user's Settings > Controls preference - (which keeps governing the launcher once this app exits). */ - poll_cfg.back_gesture = JPP_KEYPAD_BACK_GESTURE_HOLD; - } + poll_cfg.detect_double_click = center_needs_double_click(); jpp_keypad_poll(&ctx->state, &poll_cfg, sample_uv, true, events, 8u, &event_count); @@ -241,45 +324,27 @@ static void keypad_task(void *arg) events[i].key ? events[i].key : "(null)", events[i].mapped ? events[i].mapped : "(null)"); + if (keypad_handle_center_gesture(&events[i])) { + continue; + } + jpp_ui_action_t action = jpp_ui_normalize_action(&events[i]); - if (action != JPP_UI_ACTION_NONE) { - xQueueSend(s_action_queue, &action, 0); - - if (s_active_sdk_context != NULL) { - jpp_sdk_key_event_t sdk_key = JPP_SDK_KEY_NONE; - switch (action) { - case JPP_UI_ACTION_UP: sdk_key = JPP_SDK_KEY_UP; break; - case JPP_UI_ACTION_DOWN: sdk_key = JPP_SDK_KEY_DOWN; break; - case JPP_UI_ACTION_LEFT: sdk_key = JPP_SDK_KEY_LEFT; break; - case JPP_UI_ACTION_RIGHT: sdk_key = JPP_SDK_KEY_RIGHT; break; - case JPP_UI_ACTION_OK: sdk_key = JPP_SDK_KEY_CENTER; break; - case JPP_UI_ACTION_BACK: sdk_key = JPP_SDK_KEY_NONE; break; - case JPP_UI_ACTION_NONE: sdk_key = JPP_SDK_KEY_NONE; break; - } - if ((events[i].kind == JPP_KEYPAD_KIND_CENTER_LONG || - events[i].kind == JPP_KEYPAD_KIND_CENTER_DOUBLE) && - s_active_sdk_context->back_gesture_enabled) { - /* Apps only care that Back happened, not which - gesture triggered it — reuse the existing key. - Dropped entirely (sdk_key stays NONE) when the app - has suppressed it via jpp_sdk_set_back_gesture_enabled. */ - sdk_key = JPP_SDK_KEY_CENTER_LONG; - } - if (sdk_key != JPP_SDK_KEY_NONE) { - jpp_sdk_push_key(s_active_sdk_context, sdk_key); - if (s_sd_task != NULL && s_sd_is_mp && - s_active_sdk_context == &s_sd_ctx) { - jpp_vm_request_t act_req; - memset(&act_req, 0, sizeof(act_req)); - act_req.kind = JPP_VM_REQUEST_ACTION; - act_req.action_payload = (uint32_t)sdk_key; - strncpy(act_req.app_id, s_sd_ctx.app_id, - sizeof(act_req.app_id) - 1u); - jpp_vm_schedule_request(&s_sd_vm, "keypad", &act_req); - } - } - } + if (action == JPP_UI_ACTION_NONE) { + continue; + } + xQueueSend(s_action_queue, &action, 0); + + jpp_sdk_key_event_t sdk_key = JPP_SDK_KEY_NONE; + switch (action) { + case JPP_UI_ACTION_UP: sdk_key = JPP_SDK_KEY_UP; break; + case JPP_UI_ACTION_DOWN: sdk_key = JPP_SDK_KEY_DOWN; break; + case JPP_UI_ACTION_LEFT: sdk_key = JPP_SDK_KEY_LEFT; break; + case JPP_UI_ACTION_RIGHT: sdk_key = JPP_SDK_KEY_RIGHT; break; + case JPP_UI_ACTION_OK: sdk_key = JPP_SDK_KEY_CENTER; break; + case JPP_UI_ACTION_BACK: sdk_key = JPP_SDK_KEY_NONE; break; + case JPP_UI_ACTION_NONE: sdk_key = JPP_SDK_KEY_NONE; break; } + keypad_push_app_key(sdk_key); } vTaskDelay(pdMS_TO_TICKS(JPP_KEYPAD_POLL_MS)); @@ -971,8 +1036,8 @@ static void settings_do_jingle_change(uint8_t jingle) } /* ---- Back button gesture ------------------------------------------------- */ - -static uint8_t s_back_gesture_mode = JPP_KEYPAD_BACK_GESTURE_HOLD; +/* s_back_gesture_mode itself lives up beside keypad_task, which is its only + consumer. */ static void load_back_gesture(void) { @@ -983,16 +1048,16 @@ static void load_back_gesture(void) ESP_LOGI(TAG, "INPUT: back_gesture=%u", s_back_gesture_mode); } -/* Applied live: s_kpad_ctx is file-scope and the keypad task re-reads - ctx->cfg every poll, so this takes effect on the next 20ms tick with no - task restart. */ +/* Applied live: keypad_task re-derives the poll config from + s_back_gesture_mode every 20 ms tick, so this needs no task restart. A + short click already in flight is flushed rather than stranded — see + jpp_keypad_check_pending_short(). */ static void settings_do_back_gesture_change(uint8_t mode) { if (mode != JPP_KEYPAD_BACK_GESTURE_HOLD && mode != JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK) { return; } s_back_gesture_mode = mode; - s_kpad_ctx.cfg.back_gesture = (jpp_keypad_back_gesture_t)mode; jpp_nvs_set_u8(JPP_NVS_INPUT_NS, "back_gesture", mode); ESP_LOGI(TAG, "INPUT: back_gesture changed to %u", mode); } @@ -1154,7 +1219,8 @@ static void run_main_loop(jpp_ui_shell_t *shell, .repeat_enabled = true, .repeat_delay_ms = JPP_KEYPAD_DEFAULT_REPEAT_DELAY_MS, .repeat_interval_ms = JPP_KEYPAD_DEFAULT_REPEAT_INTERVAL_MS, - .back_gesture = (jpp_keypad_back_gesture_t)s_back_gesture_mode, + /* detect_double_click is re-derived per poll by keypad_task from the + user preference and the foreground app's claim. */ .double_click_ms = JPP_KEYPAD_DEFAULT_DOUBLE_CLICK_MS, .bands = KEYPAD_BANDS, .band_count = sizeof(KEYPAD_BANDS) / sizeof(KEYPAD_BANDS[0]), From fe2f82f5151311fb94e4bb9ccbda1caac56af7fd Mon Sep 17 00:00:00 2001 From: Evgeny Malevich Date: Wed, 29 Jul 2026 00:08:50 +0300 Subject: [PATCH 4/5] Test the CENTER gesture state machine on the host jpp_keypad_poll() takes one ADC sample and derives its clock from sample_index * poll_interval_ms, so the firmware source compiles natively and can be stepped a poll at a time. tests/keypad_harness.py builds it with cc and drives it over ctypes; tests/test_keypad.py covers the timing that is easy to get wrong and impossible to eyeball: - a short click is released immediately when nothing needs to tell a double-click apart, and withheld for double_click_ms when something does - a second click inside the window reports CENTER_DOUBLE and neither click leaks an OK; outside the window they stay two separate clicks - a hold reports CENTER_LONG exactly once, in both modes - a click already withheld when the mode changes is flushed rather than dropped or replayed - direction keys are untouched throughout Both of the behaviours this branch changed are covered by a test that fails if the change is reverted, checked by mutating the source and re-running. --- tests/keypad_harness.py | 194 ++++++++++++++++++++++++++++++++++++++++ tests/test_keypad.py | 130 +++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 tests/keypad_harness.py create mode 100644 tests/test_keypad.py diff --git a/tests/keypad_harness.py b/tests/keypad_harness.py new file mode 100644 index 0000000..09db005 --- /dev/null +++ b/tests/keypad_harness.py @@ -0,0 +1,194 @@ +"""Drive the real jpp_keypad_core state machine on the host. + +jpp_keypad_poll() is hardware-agnostic — one ADC sample in, debounced events +out, and its only notion of time is sample_index * poll_interval_ms — so the +firmware source can be compiled natively and stepped a poll at a time. That +makes the CENTER gesture timing (long press, the double-click window, the +deferred short click) testable without hardware. +""" + +import ctypes +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +KEYPAD_SRC = REPO_ROOT / "components" / "jpp_core" / "src" / "jpp_keypad_core.c" + +# Mirrors the KEYPAD_BANDS table in main/app_main.c. +CENTER_UV = 900000 +UP_UV = 120000 +IDLE_UV = 2700000 # far from every band, so nothing matches + +# Mirrors the config built in run_main_loop(). +POLL_MS = 20 +LONG_PRESS_MS = 700 +DOUBLE_CLICK_MS = 300 + +KIND_NO_EVENT, KIND_PRESS, KIND_RELEASE, KIND_REPEAT, \ + KIND_CENTER_SHORT, KIND_CENTER_LONG, KIND_CENTER_DOUBLE = range(7) + +KIND_NAMES = { + KIND_NO_EVENT: "NO_EVENT", + KIND_PRESS: "PRESS", + KIND_RELEASE: "RELEASE", + KIND_REPEAT: "REPEAT", + KIND_CENTER_SHORT: "CENTER_SHORT", + KIND_CENTER_LONG: "CENTER_LONG", + KIND_CENTER_DOUBLE: "CENTER_DOUBLE", +} + + +class Band(ctypes.Structure): + _fields_ = [ + ("key", ctypes.c_char_p), + ("center_uv", ctypes.c_int), + ("tolerance_uv", ctypes.c_int), + ("repeatable", ctypes.c_bool), + ] + + +class Config(ctypes.Structure): + _fields_ = [ + ("enabled", ctypes.c_bool), + ("calibration_uv", ctypes.c_int), + ("hysteresis_uv", ctypes.c_int), + ("debounce_samples", ctypes.c_int), + ("long_press_ms", ctypes.c_int), + ("poll_interval_ms", ctypes.c_int), + ("repeat_enabled", ctypes.c_bool), + ("repeat_delay_ms", ctypes.c_int), + ("repeat_interval_ms", ctypes.c_int), + ("detect_double_click", ctypes.c_bool), + ("double_click_ms", ctypes.c_int), + ("bands", ctypes.POINTER(Band)), + ("band_count", ctypes.c_size_t), + ] + + +class Event(ctypes.Structure): + _fields_ = [ + ("kind", ctypes.c_int), + ("key", ctypes.c_char_p), + ("mapped", ctypes.c_char_p), + ("duration_ms", ctypes.c_int), + ] + + +class State(ctypes.Structure): + _fields_ = [ + ("ready", ctypes.c_bool), + ("enabled", ctypes.c_bool), + ("calibration_uv", ctypes.c_int), + ("hysteresis_uv", ctypes.c_int), + ("debounce_samples", ctypes.c_int), + ("long_press_ms", ctypes.c_int), + ("poll_interval_ms", ctypes.c_int), + ("repeat_enabled", ctypes.c_bool), + ("repeat_delay_ms", ctypes.c_int), + ("repeat_interval_ms", ctypes.c_int), + ("stable_key", ctypes.c_char_p), + ("candidate_key", ctypes.c_char_p), + ("candidate_count", ctypes.c_int), + ("press_started_ms", ctypes.c_int), + ("last_repeat_ms", ctypes.c_int), + ("center_long_emitted", ctypes.c_bool), + ("sample_index", ctypes.c_size_t), + ("short_pending", ctypes.c_bool), + ("pending_release_ms", ctypes.c_int), + ] + + +_BANDS = (Band * 5)( + Band(b"UP", UP_UV, 40000, True), + Band(b"DOWN", 300000, 40000, True), + Band(b"LEFT", 500000, 40000, True), + Band(b"RIGHT", 700000, 40000, True), + Band(b"CENTER", CENTER_UV, 40000, False), +) + + +def build_library(tmp_path): + """Compile jpp_keypad_core.c into a shared library and load it.""" + cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang") + if cc is None: + pytest.skip("no C compiler available to build the keypad harness") + suffix = ".dylib" if sys.platform == "darwin" else ".so" + lib_path = tmp_path / f"libjpp_keypad{suffix}" + subprocess.run( + [cc, "-std=c11", "-shared", "-fPIC", "-O1", + str(KEYPAD_SRC), "-o", str(lib_path)], + check=True, capture_output=True, + ) + lib = ctypes.CDLL(str(lib_path)) + lib.jpp_keypad_state_init.argtypes = [ctypes.POINTER(State), ctypes.POINTER(Config)] + lib.jpp_keypad_state_init.restype = None + lib.jpp_keypad_poll.argtypes = [ + ctypes.POINTER(State), ctypes.POINTER(Config), ctypes.c_int, ctypes.c_bool, + ctypes.POINTER(Event), ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t), + ] + lib.jpp_keypad_poll.restype = ctypes.c_int + return lib + + +class Keypad: + """A keypad you can press, release and let idle, one 20 ms poll at a time.""" + + def __init__(self, lib, detect_double_click=False): + self.lib = lib + self.cfg = Config( + enabled=True, + calibration_uv=0, + hysteresis_uv=20000, + debounce_samples=2, + long_press_ms=LONG_PRESS_MS, + poll_interval_ms=POLL_MS, + repeat_enabled=True, + repeat_delay_ms=500, + repeat_interval_ms=500, + detect_double_click=detect_double_click, + double_click_ms=DOUBLE_CLICK_MS, + bands=_BANDS, + band_count=len(_BANDS), + ) + self.state = State() + self.lib.jpp_keypad_state_init(ctypes.byref(self.state), ctypes.byref(self.cfg)) + + def _poll(self, sample_uv): + events = (Event * 8)() + count = ctypes.c_size_t(0) + rc = self.lib.jpp_keypad_poll( + ctypes.byref(self.state), ctypes.byref(self.cfg), + sample_uv, True, events, 8, ctypes.byref(count), + ) + assert rc == 0, "jpp_keypad_poll reported an error" + return [ + (events[i].kind, + events[i].key.decode() if events[i].key else None, + events[i].mapped.decode() if events[i].mapped else None) + for i in range(count.value) + ] + + def run(self, sample_uv, duration_ms): + """Feed one sample for duration_ms, returning every event produced.""" + out = [] + for _ in range(max(1, duration_ms // POLL_MS)): + out += self._poll(sample_uv) + return out + + def press(self, duration_ms, key_uv=CENTER_UV): + return self.run(key_uv, duration_ms) + + def idle(self, duration_ms): + return self.run(IDLE_UV, duration_ms) + + def tap(self, key_uv=CENTER_UV): + """A press just long enough to register, well under the long-press.""" + return self.press(100, key_uv) + + +def kinds(events): + return [KIND_NAMES[k] for k, _key, _mapped in events] diff --git a/tests/test_keypad.py b/tests/test_keypad.py new file mode 100644 index 0000000..d7d5ccb --- /dev/null +++ b/tests/test_keypad.py @@ -0,0 +1,130 @@ +"""CENTER gesture behaviour of jpp_keypad_core, exercised on the host. + +The detector reports gestures only — whether a hold or a double-click means +"Back" is decided by keypad_task() in main/app_main.c from the user's +Settings > Controls preference and the foreground app's claim. What is +pinned here is the part with the subtle timing: when a short click is +released to the caller, and when a second click turns the pair into +CENTER_DOUBLE instead. +""" + +import pytest + +from keypad_harness import ( + DOUBLE_CLICK_MS, + Keypad, + build_library, + kinds, +) + + +@pytest.fixture(scope="session") +def keypad_lib(tmp_path_factory): + return build_library(tmp_path_factory.mktemp("keypad")) + + +# --- detect_double_click off: the short click is never withheld ------------- # + +def test_short_click_is_immediate_without_double_click_detection(keypad_lib): + kp = Keypad(keypad_lib, detect_double_click=False) + kp.tap() + # Only the release debounce stands between the button coming up and the + # event — nothing like the DOUBLE_CLICK_MS wait the deferred path pays. + events = kp.idle(60) + assert kinds(events) == ["CENTER_SHORT"] + assert events[0][2] == "OK" + assert 60 < DOUBLE_CLICK_MS + + +def test_double_click_never_fires_without_detection(keypad_lib): + kp = Keypad(keypad_lib, detect_double_click=False) + kp.tap() + first = kp.idle(60) + kp.tap() + second = kp.idle(60) + # Two independent OKs, no CENTER_DOUBLE anywhere. + assert kinds(first) == ["CENTER_SHORT"] + assert kinds(second) == ["CENTER_SHORT"] + + +# --- detect_double_click on: the short click waits for the window ----------- # + +def test_short_click_is_deferred_until_the_window_closes(keypad_lib): + kp = Keypad(keypad_lib, detect_double_click=True) + kp.tap() + # Nothing may be reported while a second click is still possible. + during = kp.idle(DOUBLE_CLICK_MS - 60) + assert kinds(during) == [] + after = kp.idle(120) + assert kinds(after) == ["CENTER_SHORT"] + + +def test_second_click_inside_the_window_becomes_center_double(keypad_lib): + kp = Keypad(keypad_lib, detect_double_click=True) + kp.tap() + gap = kp.idle(60) + second = kp.tap() + tail = kp.idle(DOUBLE_CLICK_MS + 100) + # The pair reports CENTER_DOUBLE and neither click leaks an OK. + assert kinds(gap) == [] + assert kinds(second + tail) == ["CENTER_DOUBLE"] + + +def test_second_click_after_the_window_is_two_separate_clicks(keypad_lib): + kp = Keypad(keypad_lib, detect_double_click=True) + kp.tap() + first = kp.idle(DOUBLE_CLICK_MS + 100) + kp.tap() + second = kp.idle(DOUBLE_CLICK_MS + 100) + assert kinds(first) == ["CENTER_SHORT"] + assert kinds(second) == ["CENTER_SHORT"] + + +# --- a hold is detected in both modes -------------------------------------- # + +@pytest.mark.parametrize("detect_double_click", [False, True]) +def test_hold_always_reports_center_long(keypad_lib, detect_double_click): + kp = Keypad(keypad_lib, detect_double_click=detect_double_click) + events = kp.press(900) + assert "CENTER_LONG" in kinds(events) + long_event = next(e for e in events if e[2] == "HOLD") + assert long_event[1] == "CENTER" + + +def test_hold_reports_center_long_exactly_once(keypad_lib): + kp = Keypad(keypad_lib, detect_double_click=False) + events = kp.press(900) + kp.idle(100) + assert kinds(events).count("CENTER_LONG") == 1 + + +def test_hold_does_not_report_a_short_click(keypad_lib): + kp = Keypad(keypad_lib, detect_double_click=True) + events = kp.press(900) + kp.idle(DOUBLE_CLICK_MS + 100) + assert "CENTER_SHORT" not in kinds(events) + + +# --- switching modes under a click already in flight ----------------------- # + +def test_pending_click_is_flushed_when_detection_is_switched_off(keypad_lib): + """Settings > Controls can flip between polls; a click already withheld + must neither be dropped nor replayed once the window no longer applies.""" + kp = Keypad(keypad_lib, detect_double_click=True) + kp.tap() + assert kinds(kp.idle(60)) == [] + kp.cfg.detect_double_click = False + flushed = kp.idle(20) + assert kinds(flushed) == ["CENTER_SHORT"] + # And it is not delivered a second time when the old window would expire. + assert kinds(kp.idle(DOUBLE_CLICK_MS + 100)) == [] + + +# --- directions are untouched by any of this ------------------------------- # + +@pytest.mark.parametrize("detect_double_click", [False, True]) +def test_direction_keys_are_unaffected(keypad_lib, detect_double_click): + from keypad_harness import UP_UV + + kp = Keypad(keypad_lib, detect_double_click=detect_double_click) + events = kp.press(100, key_uv=UP_UV) + kp.idle(60) + assert kinds(events) == ["PRESS", "RELEASE"] + assert all(e[1] == "UP" for e in events) From 537e722e5ceffa27bfbb0cc9afcdd895c11dac63 Mon Sep 17 00:00:00 2001 From: Evgeny Malevich Date: Wed, 29 Jul 2026 00:13:21 +0300 Subject: [PATCH 5/5] Docs: claim_center, policy-free keypad core, and the append-only ABI rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the Key input section of docs/sdk-reference.md around claim_center, documents JPP_SDK_KEY_BACK / _CENTER_HOLD / _CENTER_DOUBLE and the CENTER_CLAIM_* constants, and leads with the property that matters to app authors: an app that claims nothing never reads the user's Back preference. AGENTS.md gets the reworked jpp_keypad_core and jpp_sdk_bridge rows, plus two conventions that did not exist before and cost this branch time: - CENTER gesture policy lives only in keypad_task(), because it is the only code that sees both the user preference and the app's claim. Says explicitly not to push it back into the detector or into jpp_ui_normalize_action(). - SDK-visible types are append-only. Native apps are separately-built ELFs that read jpp_sdk_context_t fields directly, so a field inserted mid-struct shifts offsets in already-deployed .bin files with no load-time error. This is what the original PR did with its two bools. Drops the reference to "DOOM-lite" as the reference user for the removed calls — no such app exists in BUILTIN APPS or under apps/. Also records the keypad harness in the tests row as the pattern to copy for other pure jpp_core state machines, and adds a user-facing CHANGELOG entry for both halves of the feature. --- AGENTS.md | 10 ++++--- CHANGELOG.md | 13 +++++++++ docs/sdk-reference.md | 61 +++++++++++++++++++++++++------------------ 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4041375..eea4ea1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ jppdos/ | 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.com/lrv?ts=...&name=...&serial=...&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) | +| 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` (CENTER 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 `` | ## CODE MAP | Symbol | Type | Location | Role | @@ -68,14 +68,14 @@ jppdos/ | `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, so re-enable it after a dialog/list. `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`. Ungated surface: storage, KV, IPC, device status (now includes `username`), get_time, is_dummy_mode, UI/buzzer/LED/wakelock/canvas/module-load. `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)` (native only) 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_set_back_gesture_enabled(ctx, enabled)` (ungated, exposed to both native and MicroPython apps, defaults to `true` on every bind) lets an app drop the CENTER "Back" gesture (long-press or double-click, per Settings > Controls) instead of receiving it as `JPP_SDK_KEY_CENTER_LONG` — for apps that overload CENTER as a primary action button, so an accidental hold/double-tap mid-action doesn't yank the player into a pause/exit screen; the app re-enables it before/while showing its own pause or exit confirmation. Implemented as a plain `bool` on `jpp_sdk_context_t`, checked in `keypad_task` (`main/app_main.c`) before forwarding the gesture — does not touch UP/DOWN/LEFT/RIGHT/OK and has no effect on launcher/Settings navigation. `jpp_sdk_set_force_hold_back_gesture(ctx, force)` (ungated, exposed to both native and MicroPython apps, defaults to `false`) goes further: while forced, `keypad_task` builds the shared `jpp_keypad_config_t` passed to `jpp_keypad_poll()` with `back_gesture` pinned to `JPP_KEYPAD_BACK_GESTURE_HOLD` for as long as this app is foregrounded, overriding the system-wide Settings > Controls preference (Hold vs Double-click) at the physical-detector level rather than just at delivery — so a Double-click-mode user still gets instant "OK"/fire (no double-click defer) and only a deliberate hold (never a double-tap) reaches this app as `JPP_SDK_KEY_CENTER_LONG`. The launcher and every other app keep using the system preference unaffected; it reverts automatically when the app exits. DOOM-lite (see BUILTIN APPS) is the reference user for both calls. | +| `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, so re-enable it after a dialog/list. `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`. Ungated surface: storage, KV, IPC, device status (now includes `username`), get_time, is_dummy_mode, UI/buzzer/LED/wakelock/canvas/module-load. `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)` (native only) 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_center(ctx, mask)` (ungated, native + MicroPython, defaults to `JPP_SDK_CENTER_CLAIM_NONE` on every bind) lets an app take over CENTER gestures: **claim nothing and you get `JPP_SDK_KEY_CENTER` + `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_CENTER_CLAIM_HOLD` / `_DOUBLE`) and the claimed gestures arrive as `JPP_SDK_KEY_CENTER_HOLD`/`_DOUBLE`, `JPP_SDK_KEY_BACK` stops being delivered, and the app owns its own exit.** Claiming *only* HOLD also keeps `JPP_SDK_KEY_CENTER` instant (nothing then needs double-click discrimination, so no click is withheld) — that's the combination for an app where CENTER is a rapid action button and hold opens a pause menu. Backed by a single `uint8_t center_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_CENTER_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`, 64 KB) for the running app: executable code for native apps **or** the MicroPython GC heap. `jpp_app_pool_acquire()`/`_release()`/`_in_use()`. Native and MP apps are mutually exclusive, so one pool serves both; sharing one pool instead of reserving separate exec + GC pools keeps the static footprint minimal. 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`/`CENTER_SHORT`/`CENTER_LONG`/`CENTER_DOUBLE`). CENTER's "Back" trigger is configurable via `jpp_keypad_config_t.back_gesture` (`JPP_KEYPAD_BACK_GESTURE_HOLD`, default — hold ≥`long_press_ms` (700 ms), zero-latency OK on short release; or `JPP_KEYPAD_BACK_GESTURE_DOUBLE_CLICK` — a short click's OK is deferred until `double_click_ms` (300 ms) passes with no second click; a second short click inside that window fires `CENTER_DOUBLE`/"Back" instead, with no OK). Driven every 20 ms by `keypad_task` in `main/app_main.c`, which holds the live config in the file-scope `s_kpad_ctx` so `Settings > Controls` can flip `back_gesture` at runtime with no task restart. | +| `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`/`CENTER_SHORT`/`CENTER_LONG`/`CENTER_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 `CENTER_DOUBLE`; when clear, `CENTER_SHORT` fires the moment the button is released and `CENTER_DOUBLE` never happens. A click withheld when the flag is cleared underneath it is flushed on the next poll rather than stranded or replayed (`jpp_keypad_check_pending_short()`), which is what makes the mode safe to change at runtime. Driven every 20 ms by `keypad_task` in `main/app_main.c`, which re-derives `detect_double_click` each poll. Host-tested by `tests/test_keypad.py`. | | `jpp_buzzer_core` | component | `components/jpp_core/` | LEDC buzzer driver; predefined sounds + custom tone/sequence API. `jpp_buzzer_set_volume(percent)` / `jpp_buzzer_get_volume()` — volume is controlled via GPIO drive capability (`GPIO_DRIVE_CAP_0`–`3`), not duty cycle; duty stays fixed at 50% (`JPP_HW_BUZZER_DUTY`) for all non-zero levels so waveform quality is unchanged. 0% mutes by setting duty to 0. Default after init is 100% (CAP_3). `load_buzzer_volume()` in `app_main.c` applies the persisted level before the startup chime. `jpp_startup_jingle_t` enum (0–10): DEFAULT, WINXP, WIN31, MAC, RICKROLL, NOKIA_ON, NOKIA_TUNE, SANDSTORM, DOOM, CLUTTERFUNK, OFF. `jpp_buzzer_play_startup_jingle(jingle)` plays the chosen jingle (no-op for OFF). `jpp_startup_jingle_name(jingle)` returns the display string. Playback comes in **blocking** (`jpp_buzzer_tone`/`_play_sequence`/`_play`/`_play_startup_jingle`) and **async** (`jpp_buzzer_play_sequence_async`/`_play_async`/`_play_startup_jingle_async`) forms: async copies the sequence into an inbox and hands it to a dedicated static-allocated player task, returning immediately. Submitting a new async sequence — or calling `jpp_buzzer_stop()` — preempts whatever is playing (generation counter checked between notes), so cycling previews cut the previous one off within one note. Blocking `_play_sequence` also preempts any async sequence so the two never drive the LEDC channel at once. Settings jingle previews and the boot chime use the async form. | | `jpp_led_core` | component | `components/jpp_core/` | onboard WS2812 (GPIO8, single pixel) driver using the RMT TX peripheral directly (hand-rolled bit encoder, no `led_strip` managed-component dependency — keeps flash footprint minimal). `jpp_led_init()` is idempotent and also called lazily on first `jpp_led_set_color()`/`_off()`. Backs the ungated `jpp_sdk_led_*` SDK surface. | | `jpp_espnow_native` | component | `components/jpp_core/` | ESP-NOW send/receive driver backing `esp_now` (tier 1). Mirrors `jpp_ble_native.c`: `jpp_core` cannot depend on `main/`, so this module brings up the WiFi driver itself (STA mode, idempotent — same calls as `wifi_ensure_started()` in `main/jpp_wifi_init.c`, tolerant of "already running") rather than reaching into `main/`. Send blocks on a semaphore signalled by the ESP-NOW send callback (bounded timeout); receive pulls from an 8-entry queue fed by the recv callback — a packet is dropped if the app doesn't drain the queue often enough. `jpp_espnow_native_get_services()` follows the same `_get_services()` out-param pattern as `jpp_ble_native_get_services()`. | @@ -127,7 +127,9 @@ jppdos/ - 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()`), applied to the file-scope keypad task context `s_kpad_ctx.cfg.back_gesture`. Changed at runtime via `settings_do_back_gesture_change()` in `Settings > Controls` (one row, LEFT/RIGHT toggles) — takes effect immediately since `keypad_task` re-reads `s_kpad_ctx.cfg` every 20 ms poll, no restart needed. In **Hold** mode, behavior is unchanged from before this setting existed: holding CENTER ≥`JPP_KEYPAD_DEFAULT_LONG_PRESS_MS` (700 ms) fires Back (with auto-repeat while held), and a short release fires OK instantly. In **Double-click** mode, a short CENTER release defers OK until `JPP_KEYPAD_DEFAULT_DOUBLE_CLICK_MS` (300 ms) passes with no second click; if a second short click lands within that window the pair fires `CENTER_DOUBLE`/Back instead, with no OK for either click; holding CENTER produces no event in this mode. See `jpp_keypad_core`. +- 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 CENTER ≥`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 CENTER 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 CENTER gesture policy convention below. +- **CENTER 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.center_claim`), so it is where raw `CENTER_LONG`/`CENTER_DOUBLE` events become a Back action, a raw app key, or nothing (`keypad_handle_center_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 CENTER 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_center` 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_CENTER_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`. - 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 58badba..2e13b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ each build, not an API diff. ## Unreleased +- **Choose how the Back button works.** `Settings > Controls` now offers a + device-wide choice between holding CENTER and double-clicking it to go back. + Hold stays the default and behaves exactly as before, including the instant + OK on a short click. Picking Double-click trades a short delay on OK (the + device has to wait and see whether a second click is coming) for a Back + gesture that doesn't require holding a button down. +- **Apps can take CENTER over as a game button.** An app may now claim the + hold and/or double-click gesture as its own input, in which case it becomes + responsible for its own way out — useful for games where CENTER is a + rapid-fire action button and a stray double-tap shouldn't drop you out to + the launcher. Apps that don't claim anything keep receiving a single "back" + event and never have to care which gesture the user picked. + - **App development split out into its own repository.** The `jppd-app-sdk` Docker toolchain (`tools/app-sdk/`) and the two App SDK test apps (`apps/testapp_native`, `apps/testapp_mp`) moved to diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md index c8558a3..c85dcc1 100644 --- a/docs/sdk-reference.md +++ b/docs/sdk-reference.md @@ -6,7 +6,7 @@ |----------|-----------| | [Types and constants](#types-and-constants) | [`jpp_sdk_status_t`](#c--jpp_sdk_status_t) · [`jpp_sdk_key_event_t`](#c--jpp_sdk_key_event_t) · [Python constants](#python--jppsdk-constants) · [`jpp_broker_result_t`](#c--jpp_broker_result_t) | | [App control](#app-control) | [`set_frame`](#set_frame) · [`request_close`](#request_close) · [`log`](#log) · [`request_cap`](#request_cap-c-only) | -| [Key input](#key-input) | [`poll_key`](#poll_key) · [`wait_key`](#wait_key) · [`push_key`](#push_key-c-only) · [`set_back_gesture_enabled`](#set_back_gesture_enabled) · [`set_force_hold_back_gesture`](#set_force_hold_back_gesture) | +| [Key input](#key-input) | [`poll_key`](#poll_key) · [`wait_key`](#wait_key) · [`push_key`](#push_key-c-only) · [`claim_center`](#claim_center) | | [Canvas](#canvas) | [`canvas_write`](#canvas_write) · [`canvas_draw_pixel`](#canvas_draw_pixel) · [`canvas_clear`](#canvas_clear) · [`canvas_fullscreen`](#canvas_fullscreen) | | [UI helpers](#ui-helpers) | [`dialog`](#dialog) · [`list`](#list) · [`input`](#input) · [`confirm`](#confirm) · [`file_pick`](#file_pick) · [`wrap_text`](#wrap_text-c-only) | | [Wakelock](#wakelock) | [`wakelock_acquire`](#wakelock_acquire) · [`wakelock_release`](#wakelock_release) | @@ -73,13 +73,23 @@ Unless otherwise noted, a Python binding raises `jppsdk.SdkError` on any non-OK | `JPP_SDK_KEY_LEFT` | D-pad left | | `JPP_SDK_KEY_RIGHT` | D-pad right | | `JPP_SDK_KEY_CENTER` | D-pad center (short press) | -| `JPP_SDK_KEY_CENTER_LONG` | D-pad center long-press — the universal "back/cancel" gesture | +| `JPP_SDK_KEY_BACK` | The user asked to go back. Which physical gesture produced it (hold or double-click) depends on Settings > Controls and is not your app's concern. | +| `JPP_SDK_KEY_CENTER_LONG` | Older name for `JPP_SDK_KEY_BACK`, same value — kept so existing apps are unaffected | +| `JPP_SDK_KEY_CENTER_HOLD` | Raw CENTER hold. Delivered only if claimed via [`claim_center`](#claim_center) | +| `JPP_SDK_KEY_CENTER_DOUBLE` | Raw CENTER double-click. Delivered only if claimed via [`claim_center`](#claim_center) | ### Python — `jppsdk` constants ```python # Key events (match C enum values) -KEY_NONE, KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, KEY_CENTER, KEY_CENTER_LONG +KEY_NONE, KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, KEY_CENTER +KEY_BACK, KEY_CENTER_LONG # same value; KEY_BACK is preferred +KEY_CENTER_HOLD, KEY_CENTER_DOUBLE # raw gestures, only if claimed + +# CENTER gesture claims (see claim_center) +CENTER_CLAIM_NONE = 0 +CENTER_CLAIM_HOLD = 1 +CENTER_CLAIM_DOUBLE = 2 # File open modes OPEN_READ = 0 @@ -267,51 +277,52 @@ void jpp_sdk_push_key(jpp_sdk_context_t *ctx, jpp_sdk_key_event_t event); --- -### `set_back_gesture_enabled` +### `claim_center` -Enable or disable delivery of the "Back" gesture to your app. +Take over CENTER gestures as your own input. **Capability:** None ```c -jpp_sdk_status_t jpp_sdk_set_back_gesture_enabled(jpp_sdk_context_t *ctx, bool enabled); +jpp_sdk_status_t jpp_sdk_claim_center(jpp_sdk_context_t *ctx, uint8_t mask); ``` ```python -jppsdk.set_back_gesture_enabled(enabled: bool) -> None +jppsdk.claim_center(mask: int) -> None ``` **Parameters:** | Name | Description | |------|-------------| -| `enabled` | `true` (the default) delivers the gesture as `JPP_SDK_KEY_CENTER_LONG`; `false` drops it silently — your app receives nothing for it. | +| `mask` | Bitwise OR of `JPP_SDK_CENTER_CLAIM_HOLD` and `JPP_SDK_CENTER_CLAIM_DOUBLE`, or `JPP_SDK_CENTER_CLAIM_NONE` (the default on every bind). In MicroPython: `jppsdk.CENTER_CLAIM_HOLD`, `jppsdk.CENTER_CLAIM_DOUBLE`, `jppsdk.CENTER_CLAIM_NONE`. | -**Notes:** CENTER's "Back" trigger — a long hold or a double-click, whichever Settings > Controls has selected — always arrives as `JPP_SDK_KEY_CENTER_LONG`, same as today. If your app uses CENTER as a primary action button (e.g. "fire" in a shooter), an excited player can easily hold or double-tap it by accident and get yanked into a pause/exit screen mid-action. Call `set_back_gesture_enabled(ctx, false)` while that would be disruptive, and re-enable it before or while showing your own pause menu or exit confirmation, so there's still an explicit, discoverable way out. Disabling it does not affect `UP`/`DOWN`/`LEFT`/`RIGHT`/`OK`, and has no effect outside your app (the launcher and Settings are unaffected). Resets to enabled the next time your app is (re)bound. +**Behaviour:** ---- +| Claim | Your app receives | Back | +|---|---|---| +| *(nothing — the default)* | `KEY_CENTER` | `KEY_BACK` | +| `HOLD` | `KEY_CENTER` + `KEY_CENTER_HOLD` | your own | +| `DOUBLE` | `KEY_CENTER` + `KEY_CENTER_DOUBLE` | your own | +| `HOLD \| DOUBLE` | `KEY_CENTER` + both | your own | -### `set_force_hold_back_gesture` +**Notes:** The device has a user preference (Settings > Controls) for whether a long hold or a double-click means "Back". **Your app never needs to read it.** Claim nothing and you get `JPP_SDK_KEY_BACK` whenever the user asks to go back, with the firmware deciding which physical gesture that was — settings-agnostic by construction. -Force CENTER's "Back" trigger to always be a hold for your app, regardless of the system-wide Settings > Controls preference. +Claim a gesture and it becomes yours: it arrives as `JPP_SDK_KEY_CENTER_HOLD` / `JPP_SDK_KEY_CENTER_DOUBLE`, `JPP_SDK_KEY_BACK` stops being delivered, and **your app is responsible for its own way out** (a pause menu, an on-screen Exit item). That is the trade for owning the gesture, and it applies whichever gesture you claimed. -**Capability:** None +Claiming *only* `HOLD` additionally keeps `JPP_SDK_KEY_CENTER` instant. Telling a double-click apart requires withholding the first click for a few hundred milliseconds; when nobody needs that distinction, nothing is withheld. This is the combination for an app where CENTER is a rapid action button and hold opens a pause menu: ```c -jpp_sdk_status_t jpp_sdk_set_force_hold_back_gesture(jpp_sdk_context_t *ctx, bool force); -``` -```python -jppsdk.set_force_hold_back_gesture(force: bool) -> None +jpp_sdk_claim_center(ctx, JPP_SDK_CENTER_CLAIM_HOLD); +/* ... */ +switch (key) { +case JPP_SDK_KEY_CENTER: fire(); break; +case JPP_SDK_KEY_CENTER_HOLD: pause_menu(); break; +} ``` -**Parameters:** +Never affects `UP`/`DOWN`/`LEFT`/`RIGHT`, and never affects the launcher or Settings. The claim lives on your context and is dropped when your app exits. -| Name | Description | -|------|-------------| -| `force` | `true` evaluates CENTER as a hold (`JPP_KEYPAD_BACK_GESTURE_HOLD`) for your app's key stream no matter what Settings > Controls says; `false` (the default) follows the system-wide preference like any other app. | - -**Notes:** If your app overloads CENTER as a primary action button, the system's Double-click preference is actively harmful to it in two ways: every short click ("OK"/fire) is deferred a few hundred ms waiting to see if a second click follows, and two rapid taps of your action button can pair up into an accidental "Back". Forcing Hold mode removes both problems — fire is instant, and only a deliberate hold ever reaches you as `JPP_SDK_KEY_CENTER_LONG`. This changes what the shared keypad detector does while your app is foregrounded; it has no effect on the launcher or any other app, and reverts to the system preference automatically the moment your app exits. See also `set_back_gesture_enabled`, which controls delivery rather than the underlying gesture detection — the two can be combined. - ---- +`JPP_SDK_KEY_BACK` is the preferred spelling of `JPP_SDK_KEY_CENTER_LONG` — the same value under a name that no longer implies a particular gesture. Existing code using `JPP_SDK_KEY_CENTER_LONG` is unaffected. ---