diff --git a/AGENTS.md b/AGENTS.md index a8f3c44..eea4ea1 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 | @@ -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,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_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`). **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()`. | @@ -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,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()`) 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. @@ -142,7 +146,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` / `testapp_mp` | SDK Test (C) / (MP) | The two App SDK test apps — menu-driven exercises of every SDK capability, in C and MicroPython. **No longer in this repo**: they live in the sibling `jppdos-apps` repo (`apps/`) and are built with `jppd-build`, not `idf.py`. Still the reference for SDK behaviour when changing `jpp_sdk_bridge` | 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/components/jpp_core/include/jpp_keypad_core.h b/components/jpp_core/include/jpp_keypad_core.h index 0bf945c..e3e109a 100644 --- a/components/jpp_core/include/jpp_keypad_core.h +++ b/components/jpp_core/include/jpp_keypad_core.h @@ -14,6 +14,17 @@ 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 + +/* 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, +} jpp_keypad_back_gesture_t; typedef enum { JPP_KEYPAD_KIND_NO_EVENT = 0, @@ -22,6 +33,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 +53,13 @@ typedef struct { bool repeat_enabled; int repeat_delay_ms; int repeat_interval_ms; + /* 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; } jpp_keypad_config_t; @@ -70,6 +89,14 @@ typedef struct { int last_repeat_ms; bool center_long_emitted; size_t sample_index; + /* 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; 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..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 @@ -529,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); @@ -973,6 +994,31 @@ 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 — 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 022bd2d..9f8a1fb 100644 --- a/components/jpp_core/src/jpp_keypad_core.c +++ b/components/jpp_core/src/jpp_keypad_core.c @@ -68,6 +68,60 @@ static int jpp_keypad_config_repeat_interval_ms(const jpp_keypad_config_t *confi return config->repeat_interval_ms; } +static bool jpp_keypad_config_detect_double_click(const jpp_keypad_config_t *config) +{ + if (config == NULL) { + return false; + } + return config->detect_double_click; +} + +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; +} + +/* 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, + jpp_keypad_event_t *events, + size_t event_capacity, + size_t *event_count +) +{ + if (state == NULL || !state->short_pending) { + return 0; + } + 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->short_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,24 +260,50 @@ static int jpp_keypad_finalize_release( duration_ms = 0; } if (strcmp(previous_key, "CENTER") == 0) { - if (duration_ms >= jpp_keypad_config_long_press_ms(config) && !state->center_long_emitted) { + 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; + } + /* 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; } @@ -265,6 +345,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->short_pending = false; + state->pending_release_ms = -1; state->ready = state->enabled; } @@ -283,6 +365,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 +395,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_short(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,6 +421,9 @@ int jpp_keypad_poll( state->press_started_ms = now_ms; } if (strcmp(candidate_key, "CENTER") == 0) { + /* 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) { @@ -342,7 +432,7 @@ int jpp_keypad_poll( 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].mapped = "HOLD"; events[*event_count].duration_ms = duration_ms; *event_count += 1u; } else if (state->center_long_emitted && @@ -356,7 +446,7 @@ int jpp_keypad_poll( 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].mapped = "HOLD"; events[*event_count].duration_ms = 0; *event_count += 1u; } diff --git a/components/jpp_core/src/jpp_mp_sdk_module.c b/components/jpp_core/src/jpp_mp_sdk_module.c index 697623f..ad31aae 100644 --- a/components/jpp_core/src/jpp_mp_sdk_module.c +++ b/components/jpp_core/src/jpp_mp_sdk_module.c @@ -364,6 +364,21 @@ 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); +/* claim_center(mask) → None */ +STATIC mp_obj_t mp_sdk_claim_center(mp_obj_t mask_obj) +{ + mp_int_t mask = mp_obj_get_int(mask_obj); + if (mask < 0 || mask > 0xFF) { + raise_sdk_error(JPP_SDK_STATUS_INVALID_ARGUMENT, NULL); + } + 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_claim_center_obj, mp_sdk_claim_center); + /* -------------------------------------------------------------------------- */ /* BLE — scan (requires: ble.scan) */ /* -------------------------------------------------------------------------- */ @@ -1096,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) }, @@ -1127,6 +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_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 9046ed9..df3d2d7 100644 --- a/components/jpp_core/src/jpp_sdk_bridge.c +++ b/components/jpp_core/src/jpp_sdk_bridge.c @@ -334,6 +334,8 @@ 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)); } @@ -2549,6 +2551,17 @@ 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_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; } + 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; +} + /* -------------------------------------------------------------------------- */ /* High-level UI helpers: Dialog, List, Input */ /* */ 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 1055479..aa3d8ea 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,14 @@ 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_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 de14915..1a7d700 100644 --- a/components/micropython/qstrdefsport.h +++ b/components/micropython/qstrdefsport.h @@ -31,6 +31,13 @@ Q(log) Q(device_status) Q(get_time) Q(is_dummy_mode) +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/docs/sdk-reference.md b/docs/sdk-reference.md index 03dad12..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) | +| [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,6 +277,55 @@ void jpp_sdk_push_key(jpp_sdk_context_t *ctx, jpp_sdk_key_event_t event); --- +### `claim_center` + +Take over CENTER gestures as your own input. + +**Capability:** None + +```c +jpp_sdk_status_t jpp_sdk_claim_center(jpp_sdk_context_t *ctx, uint8_t mask); +``` +```python +jppsdk.claim_center(mask: int) -> None +``` + +**Parameters:** + +| Name | Description | +|------|-------------| +| `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`. | + +**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 | + +**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. + +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. + +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_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; +} +``` + +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. + +`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. + +--- + ## 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..ce5418a 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -199,6 +199,96 @@ typedef struct { jpp_keypad_state_t state; } keypad_task_ctx_t; +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; @@ -223,7 +313,9 @@ 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; + poll_cfg.detect_double_click = center_needs_double_click(); + jpp_keypad_poll(&ctx->state, &poll_cfg, sample_uv, true, events, 8u, &event_count); for (size_t i = 0u; i < event_count; i++) { @@ -232,39 +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) { - 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)); @@ -342,6 +422,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 +691,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 +708,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 +1035,33 @@ static void settings_do_jingle_change(uint8_t jingle) jingle, jpp_startup_jingle_name((jpp_startup_jingle_t)jingle)); } +/* ---- Back button gesture ------------------------------------------------- */ +/* s_back_gesture_mode itself lives up beside keypad_task, which is its only + consumer. */ + +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: 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; + 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 +1207,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 +1219,16 @@ 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, + /* 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]), }; - 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 +1253,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 +1305,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 +1429,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 +2054,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. */ 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)