diff --git a/.gitignore b/.gitignore index 908dca8..f058573 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ scripts/build/wifi_secrets.h # Local working notes + agent config (contain ops/infra details — never publish) CLAUDE.md .claude/ +/dev-docs/ +/crash-dumps/ # Custom timestamped build outputs /builds/ diff --git a/NOTICE b/NOTICE index 1d8bcd9..7bc5776 100644 --- a/NOTICE +++ b/NOTICE @@ -51,15 +51,27 @@ ed25519 (bundled in lib/ed25519) AsyncElegantOTA (vendored in arch/esp32/AsyncElegantOTA) Copyright (c) Ayush Sharma — MIT License -TFT_eSPI (Heltec V4 display backend) +TFT_eSPI (T-LoRa Pager display backend) Copyright (c) Bodmer — MIT / FreeBSD License — https://github.com/Bodmer/TFT_eSPI -Adafruit GFX / ST7735_ST7789 (T-Deck display backend) +Adafruit GFX / ST7735_ST7789 (T-Deck / Heltec V4 display backend) Copyright (c) Adafruit Industries — BSD License chsc6x (Heltec V4 CHSC6x cap-touch driver) Quency-D — https://github.com/Quency-D/chsc6x +SensorLib (T-LoRa Pager XL9555/BQ27220/AW9364 drivers) + Copyright (c) Lewis He — MIT License — https://github.com/lewisxhe/SensorLib + +ES8311 register sequence and clock-divider coefficients (T-LoRa Pager audio +codec driver, variants/lilygo_tlora_pager/Es8311Codec.cpp) + Copyright (c) 2023 Espressif Systems (Shanghai) CO LTD + Apache License 2.0 — https://github.com/espressif/esp-adf-libs + wadamesh's driver is original code (not a copy of esp_codec_dev's + plugin/HAL architecture) but its ES8311 register addresses, startup/ + power sequence, and MCLK clock-divider coefficients are derived from + Espressif's public es8311.c/es8311_reg.h. + LovyanGFX (RAK WisMesh Tap V2 display backend) Copyright (c) lovyan03 — FreeBSD License — https://github.com/lovyan03/LovyanGFX diff --git a/TLORA_PAGER_PORT.md b/TLORA_PAGER_PORT.md new file mode 100644 index 0000000..1715751 --- /dev/null +++ b/TLORA_PAGER_PORT.md @@ -0,0 +1,996 @@ +# T-LoRa Pager port — working tracker + +Goal: run the **full** wadamesh UI + functionality on the **LilyGo T-LoRa Pager** +(ESP32-S3, **LR1121** radio variant first), kept as **one codebase** with the +existing boards so a UI change ships everywhere at once. This file is the running +plan/status — update it as we go. + +Status: **M7's UI portion DONE — boot logo, full LVGL UI, QWERTY +keyboard nav and rotary encoder all VERIFIED WORKING on real hardware +(2026-07-07)**, after root-causing the intermittent black screen to the ST7796 +panel's hardware-reset line floating: it's wired to **XL9555 channel 6**, which +both the LilyGoLib doc's channel table and the canonical arduino-esp32 pins +header omit (full war story: `TLORA_PAGER_M7_HW_DEBUG_LOG.md`, session 2 — +including why session 1's radio/display "SPI race" theory was disproven from +TFT_eSPI source). Hardware-comms gates split out to **Milestone 7b**: radio +vs. a live mesh node is now **DONE** (adverts/DMs confirmed reliable both +directions as of 2026-07-11, after the TX-power fix — risk 10; still watch +upstream LR1121 ACK issue #1376 for anything beyond basic reliability), **SD +storage is DONE** (mount/browse/read/write/delete/insert-remove all +hardware-verified — risk 1h), and USB companion device-profile frame +remains untested — see ⑦b for the current breakdown. **Milestone 8 +(on-device UI pass) is now active**: fixing weird UI/keyboard behaviors found +by manual testing on the device, human supplying photos/repro steps. +Notification sound (ES8311 codec + NS4150B amp) landed in **Milestone 8b** — +hardware-verified 2026-07-08 (commit `e682fbc`). **M8 is still active** +(on-device UI pass); besides the nav-coverage/222-px audit that's its actual +stated scope (still not started, see risk 8 — this is the single biggest +remaining item), several concrete bugs surfaced and got fixed this +milestone: the radio-settings blur-autosave lag (risk 12), the encoder not +scrolling open dropdowns (fixed 2026-07-08, commit `4bbfcb5`), the keyboard +backlight never being wired up (risk 9, fixed 2026-07-10), and microSD file +manager + WAV-picker support (risk 1h, fixed 2026-07-10, scoped — DataStore +SD routing/chat-history/tiles/wallpaper/backup remain deferred). An +unread-message LED notification was investigated and explicitly decided +against (risk 13) — this board has no onboard LED at all. Risk 11 +(keyboard/encoder-driven scroll not repainting) was **ACCEPTED as +workaround-final 2026-07-13** — the Alt+turn free-scroll gesture is the +permanent, intended fix for this hardware, root cause not being pursued +further absent new evidence (see risk 11). **Milestone 10 (SX1262-variant +env) is DONE and hardware-verified** (2026-07-13, `tlora_pager_sx1262_ +companion_radio_touch` — see worklist ⑩): a real SX1262 unit completed +setup and confirmed bidirectional live-mesh messaging (public, a custom `#` +channel, and DM). A cluster of keyboard/encoder input-ergonomics fixes +landed 2026-07-08 through 2026-07-14 — Enter/Backspace chat-bubble parity, +the Shift/Caps rework, the accent-variant and @-mention popups both made +encoder-navigable, WASD map panning + Q/E slider nudging, the Fn+Shift +chord's conditional Caps-Lock-vs-Home-jump behavior, and app-drawer tiles' +focus highlight matching their touch-press accent tint — all hardware- +verified; see the dated paragraphs below and the UI-changes inventory table +for specifics. **M8's own stated scope (the 222-px vertical-layout audit + +full focus-nav-coverage pass) still has not been started** — everything +landed in M8 so far has been reactive bug-fixing from user-reported issues, +not the systematic audit itself (risk 8) — this remains the single biggest +open item in the port. + +**M8 session 2026-07-07 findings** (radio + nav, all committed: +`b3c6388`/`df7f9bc`): +- **TX power was silently capped at a conservative 10dBm with no way to raise + it** — unlike the T-Deck (boots straight at 22dBm). Found while + investigating a real-mesh DM test: adverts/DMs sent FROM the pager were + unreliable while inbound reception was fine (the far node's own TX was + unaffected) — classic asymmetric-range signature. Fixed to + `LORA_TX_POWER=22`, confirmed as this chip's real sub-GHz HP-PA ceiling via + RadioLib's `LR1120::checkOutputPower()` (LR1121 inherits it: HP PA + auto-selects above 14dBm, valid range -9..22 below 1GHz) and trail-mate's + own `TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22` for this exact board. Existing + saved prefs on an already-flashed device do NOT pick up a new compiled + default — only a fresh/factory-reset device does; raise it manually in + Settings → Radio & Mesh to test on a unit that predates this fix. +- **Risk 1g (RX-boosted-gain never applied for LR1121) fixed** — see risk + entry below. +- **Encoder direction was inverted** — confirmed on hardware, not a + guess: turning the way a user expects to move forward/down moved + focus/scroll backward/up. Fixed in `PagerEncoder.cpp`'s + `pagerEncoderReadDelta()` (negated once at the source, so every consumer + inherits the fix) rather than in UI code. +- **Found a real, still-partially-open rendering bug**: keyboard/encoder- + driven `lv_obj_scroll_to_view(f, LV_ANIM_OFF)` (in `navFocusCb`) does NOT + reliably repaint the newly-scrolled region on this board — confirmed via + hardware logging that focus and DEFOCUSED save side-effects (blur-to-save + toasts) correctly reach fields far down a settings page, but the glass + keeps showing the OLD scroll position throughout. Small new overlays + (the toast itself) still paint fine, since those are fresh objects, not a + repaint of already-rendered scrolled content. Touch-drag scrolling on the + other boards never surfaced this because LVGL's drag/animation handler + re-invalidates every animation tick, papering over the same gap that a + single one-shot `LV_ANIM_OFF` scroll doesn't cover. **Root cause NOT fixed + yet** — an attempted fix (force `lv_obj_invalidate()` on the scrolled + ancestor after `scroll_to_view`) made things WORSE (very slow, ~5 presses + per visible update) without actually fixing the viewport scroll, and was + reverted. **Workaround shipped instead**: holding the pager's orange Alt + key + turning the encoder now free-scrolls the page via + `navScrollFocused()`'s ANIMATED `lv_obj_scroll_by` (which DOES repaint + correctly, consistent with the animation-tick theory above), then snaps + focus to whatever's nearest the top of the now-visible viewport + (`navRefocusFirstVisible()`). This also fixed a pre-existing bug where + Alt+turn always jumped main tabs even from inside a settings sheet/chat — + now scoped to the main-tab level only (`navOnMainPage()`). Plain + NEXT/PREV-driven `scroll_to_view` on a tall settings page is still broken; + picking that apart further (why does one-shot `LV_ANIM_OFF` fail to + invalidate on THIS board specifically?) is unscheduled follow-up — likely + worth checking whether `lv_timer_handler()`'s own per-pass duration + (`[STALL] ui:lvgl`, separately observed at 400ms–1.6s on this board during + this investigation) is a contributing/related factor, not just the + scroll-invalidate call itself. + +**M8 session 2026-07-08, chat-bubble Enter/Backspace ergonomics (commits +`facbe49`/`bf5c682`):** a focused non-composer widget (e.g. a Settings row) +didn't activate on Enter in one specific case — fixed. Also added, matching +Tanmatsu's identical no-touch gestures: Enter on a focused chat message +bubble opens the same Ack/Mention/Copy/Info/Block action menu a touch +long-press opens elsewhere (`navEnterBubble()`, shared code — bubbles are +the focusable leaves inside a chat's `msgs` container, identified by +parent); Backspace jumps straight to the newest message when the "scroll to +bottom" button is showing (this board has no touch to tap that floating +button). The rotary encoder's own short click did NOT get the bubble-menu +behavior in this pass — that gap wasn't closed until 2026-07-14 (below). + +**M8 session 2026-07-13, Shift/Caps rework + accent-popup keyboard nav +(commits `79df528`/`43f040e`), hardware-verified:** (1) The Shift/Caps key +(`kCapsPos`, renamed `kShiftPos`) changed from trail-mate's original +bare-press-toggle-Caps-Lock to conventional semantics — held alone it's a +momentary Shift (uppercases while held, releases back to normal); held Alt +(Fn) then a Shift press chords into a Caps Lock toggle. New `s_shift_held` +static, same pattern as `s_backspace_held`/`s_space_held`. One documented +edge case: Alt+Shift+O or Alt+Shift+L all three held phantom-ghosts a stray +'q'/'a' (Alt and Shift share row2; col8 is 'o'/'l' on rows 0/1 — a +diode-less-matrix 3-key rectangle, no software fix possible, harmless since +it's not the intended hold-Alt-tap-Shift gesture). (2) The accent-variant +popup (issue #22 — shows accented options when typing a vowel or +`n c s y t z l r`) was completely unreachable on this no-touch board — its +cells are `NAV_SKIP_FLAG`, excluded from the nav group by design (touch +boards pick them by tap). Added a pager-only parallel nav path: Fn(Alt)+ +Space arms it, the rotary encoder walks the highlighted variant, the +encoder's own click confirms (synthesizes `LV_EVENT_CLICKED` on the +highlighted cell via `accentNavConfirm()`, reusing `accentBoxCellCb` rather +than duplicating its logic), Backspace cancels without deleting the base +letter. See worklist ⑤ for the full writeup, including the 2026-07-14 +follow-up that changed what the Alt+Shift chord actually *does*. + +**Same session, Milestone 10 (SX1262-variant env) — DONE, hardware-verified +(commit `4b0c2e9`):** a real SX1262 unit completed setup and confirmed +bidirectional live-mesh messaging (public, a custom `#` channel, and DM). +Full writeup, including the `target.h` missing-include bug found and fixed, +is in worklist ⑩ and `TLORA_PAGER_PORT_MILESTONES.md`'s Milestone 10. + +**Same session, WASD map panning + Q/E slider nudge (commit `9881bb0`), +hardware-verified:** the Map tab's pan/drag needed a no-touch equivalent — +W/A/S/D now pan north/west/south/east while the Map tab is active, reusing +`mapNudge()` (previously Tanmatsu's Ctrl+Arrow-only helper, widened to +`defined(HAS_TANMATSU) || defined(TLORA_PAGER)`). This freed up D from the +existing slider-nudge binding (added 2026-07-08 for Control Center +brightness / Settings sliders / the map zoom bar, since none of those can +be dragged without touch or a trackball), which moved from D/F to Q/E so +the two gestures never collide (e.g. the map zoom slider focused while +WASD-panning). + +**M8 session 2026-07-14, four more hardware-verified fixes:** +1. **Encoder short click now opens the message action menu too** (commit + `c4c28d4`) — the 2026-07-08 fix above only wired this into keyboard + Enter; the encoder's short click still sent a plain `LV_KEY_ENTER` a + bubble doesn't react to. `updatePagerEncoder()`'s short-click branch now + calls `navEnterBubble()` first, mirroring `handleHwKey()` exactly. +2. **Fn+Shift's effect now depends on UI state** (commit `b16206b`) — the + chord used to toggle Caps Lock unconditionally, even with no text field + focused to see the change reflected in, which was reported as surprising + and purposeless outside of typing. `PagerKeyboard.cpp` no longer decides + the effect (it has no UI visibility) — it only reports the chord via + `pagerKeyboardConsumeAltShiftChord()`; `UITask.cpp`'s new + `updatePagerAltShiftChord()` toggles Caps Lock ONLY while a text field is + actually being edited (same "ta" derivation `handleHwKey()`'s TLORA_PAGER + branch already uses to tell a bound-but-unfocused field apart from one + actually being edited), and jumps straight to Home (`navGoToMainTab`) + everywhere else. +3. **@-mention contact picker made encoder-navigable** (commit `57802eb`) — + the same unreachable-without-touch problem the accent box had, fixed the + same way (`mentionNavRestyle()`/`mentionNavConfirm()` mirror + `accentNavRestyle()`/`accentNavConfirm()` almost exactly), with one + deliberate difference: it grabs the encoder the INSTANT the list appears + rather than requiring an explicit Fn+Space arm, since — unlike the accent + box, which pops up after nearly every letter typed and must not steal + focus from ongoing typing — the mention list only shows once the user + has deliberately typed "@partial" looking for someone to pick, so there's + no ongoing typing to protect. +4. **App-drawer tiles' keyboard/encoder focus highlight normalized to their + own accent tint** (commit `6073b6f`) — tiles previously fell through to + `navFocusCb`'s generic white reverse-video fill when focused via + keyboard/encoder nav (a solid white block over a small rounded icon chip + read as a glitch), instead of the accent-tinted `LV_STATE_PRESSED` look + they already show on a touch press. New `NAV_ACCENTFOCUS_FLAG` + (`LV_OBJ_FLAG_USER_3`) lets `navFocusCb` special-case flagged objects + (mirrors the existing switch/slider special-case) to use that same + accent tint for both states, on every board — not just the pager. + +All three envs build green — Milestone ⑥ (UITask wiring) +landed; the pager is a first-class, non-touch UI target. Board JSON verified +(`boards/lilygo-t-lora-pager.json`). Milestones ①–⑥ (variant skeleton, LR1121 +radio glue, ST7796 display + AW9364 backlight, platformio.ini env, TCA8418 +keyboard + rotary encoder drivers, UITask wiring) landed. +`[env:tlora_pager_lr1121_companion_radio_touch]` compiles clean: **RAM 23.8% +(78088/327680 B), Flash 66.4% (2696465/4063232 B)** — both still comfortably +lower than the two shipping boards (Heltec V4/T-Deck: RAM 25.3%, Flash 73.5%, +unchanged — confirmed byte-identical to pre-M6, zero regression). M1–M5's +build-blocking fixes (variant/pins split, radio-wrapper include paths, the +minimal display-typedef addition) are recorded in Decisions ②/⑧ and worklist +③/⑤ below; M6's own findings follow. + +**M6 findings** — the UI-inventory table + worklist ⑥ below have full detail; +the highlights: +- **Two capability flags I initially set were premature and had to be walked + back**: `CAP_SD`/`CAP_FILESYSTEM` looked right from the hardware table (the + pager does have a microSD slot), but the actual mount code + (`fmSdTryMount()`, `#include `) is still hardcoded to + `HAS_TDECK_GT911` specifically — `device_caps.h`'s own `CAP_SD` flag was + never actually wired to it. Turning it on for the pager just produced + `SD`/`CARD_NONE`/`fmSdTryMount` "not declared" errors, not real SD support. + Set back to 0 — a real mount needs its own pager-specific wiring (CS 21, a + non-T-Deck shared-SPI helper), unscheduled follow-up, not this milestone. +- **`navMaybeRebuild()` — the function that actually populates the LVGL focus + group every screen — was unreachable for the pager** in my first pass: it's + only called under `#if defined(HAS_TANMATSU) ... #elif CAP_TRACKBALL ...`, + neither of which the pager matches. Without a fix here the KEYPAD indev + would have an eternally-empty focus group — nothing focusable, navigation + completely dead, despite the indev registration itself being correct. Added + a `#elif defined(TLORA_PAGER)` arm. +- **A self-inflicted `#elif` scoping mistake**: `handleHwKey()` (and several + helpers it calls) live inside a large, multiply "paused and reopened" + `#if defined(HAS_TDECK_KEYBOARD)` region. Widening the wrong reopen point + first orphaned ~50 lines of genuinely T-Deck-specific code (keyboard + backlight-mode timer, notify-flash, spacebar-lock countdown) into what was + meant to be the pager's own simpler branch. Fixed by tracing each reopen's + actual `#endif` (nesting depth, not just grep hits) before touching it, and + keeping the T-Deck-only pieces under their own unwidened, more specific gate. +- **`isDismissKey()`'s T-Deck logic doesn't transfer**: it treats the letters + p/q/a as "dismiss popup" because the T-Deck's sparse keyboard has no + dedicated Esc key. The pager's full QWERTY types those letters constantly — + reusing that mapping would eat normal typing. Returns `false` for the pager + instead; the rotary encoder's long-press already covers ESC. +**Execution playbook: `TLORA_PAGER_PORT_MILESTONES.md`** — the worklist below, +broken into agent-executable milestones with gates and per-file instructions. + +--- + +## Why this port is cheaper than it looks + +The scary part — "the device has no touchscreen" — is **already solved in this +codebase**. UITask carries a complete non-touch navigation layer built for the +Tanmatsu (keypad-only) and the T-Deck trackball D-pad mode: + +- **Focus-group nav**: `s_nav_group` (`lv_group_t`), amber focus ring + scroll-into-view + (`navFocusCb`), per-screen group rebuild (`navMaybeRebuild`), 2-D directional focus + (`navMoveDir`), tab-bar handling (`navOnTabBar`/`navSwitchTab`) — `UITask.cpp` ~2138–3128. +- **A KEYPAD indev + key FIFO**: `navFifoPush/Pop` feeds `LV_KEY_UP/DOWN/LEFT/RIGHT/ + NEXT/PREV/ENTER/ESC` into `tanmatsuKeypadRead` (`UITask.cpp` ~3000). The Tanmatsu + registers ONLY this indev (no pointer) — `UITask.cpp` ~35706–35717. **That branch is + the pager's template.** +- **Physical-keyboard routing**: `handleHwKey()` (`UITask.cpp` ~27990) routes keys + into the focused textarea (edit mode) or into nav (navigate mode, `s_nav_ta_editing` + flag), with the on-screen LVGL keyboard suppressed — exactly how the T-Deck works + today. The pager's QWERTY plugs into this unchanged. +- **Rotary encoder**: no `LV_INDEV_TYPE_ENCODER` needed — encoder ticks map to + `navFifoPush(LV_KEY_NEXT/PREV)` (focus walk), press → `ENTER`, long-press → `ESC`. + Reuses everything above. + +So the genuinely NEW work is: the **480×222 ST7796 display driver + layout pass**, +the **TCA8418 keyboard driver** (raw matrix → chars, unlike the T-Deck's C3 which +resolves ASCII for us), the **rotary driver**, the **LR1121 radio glue**, and a +**board class** whose battery/power goes through I²C chips (fuel gauge + IO +expander) instead of an ADC pin. + +## Hardware / platform facts + +Confirmed from the LilyGo product page, CNX-Software (2025-08-12), Meshtastic docs, +and cross-checked against three working/authoritative sources: upstream +`meshcore-dev/MeshCore`'s pager target (see caveat under Decision ②), the official +[LilyGoLib hardware page](https://github.com/Xinyuan-LilyGO/LilyGoLib/blob/master/docs/hardware/lilygo-t-lora-pager.md), +and `~/dev/trail-mate` (local project with a running LR1121 pager build — pin map +below is from its `variants/lilygo_tlora_pager/pins_arduino.h`, which is +byte-identical on every pin/channel to the canonical +[`espressif/arduino-esp32` pins_arduino.h](https://github.com/espressif/arduino-esp32/blob/master/variants/lilygo_tlora_pager/pins_arduino.h) +for this board). **Caveat**: the LilyGoLib page's own "Pins Map" table has +internal inconsistencies for the XL9555 channel assignments (e.g. it lists +keyboard-enable at ch10 and SD-detect/enable at ch12/ch14, which contradicts +its own "PowerManage Channel" table on the same page *and* the canonical +arduino-esp32 header). We treat the arduino-esp32 header + trail-mate (which +agree with each other and with the "PowerManage Channel" table) as ground +truth — that's what our own `TLoraPagerBoard.cpp` already uses. + +| | | +|---|---| +| SoC | ESP32-**S3** @ 240 MHz, 16 MB flash (QIO), **8 MB QSPI PSRAM** (`memory_type: qio_qspi`) | +| Display | 2.33" IPS **ST7796U**, 480×222 (221 PPI, 262K colors, 450 cd/m²), **480×222 landscape**, SPI, **no touch**. CS 38, DC 37, backlight 42 (AW9364 16-level stepped driver). **Hardware reset is XL9555 ch6, NOT unwired** — `TFT_RST=-1` only because it isn't an ESP GPIO; `TLoraPagerBoard::begin()` owns the reset pulse (LOW→50ms→HIGH, vendor sequence). Leaving ch6 floating = intermittent black screen, clean boot log (M7 root cause) | +| Shared SPI bus | **SCK 35, MOSI 34, MISO 33** — display + LoRa + SD + ST25R3916 NFC all on it (like the T-Deck's 40/41/38 — solved pattern, CS discipline + SPI transactions) | +| Radio | **LR1121** (sub-GHz 830–945 MHz + 2.4 GHz; we use sub-GHz only). CS 36, RST 47, BUSY 48, IRQ/DIO1 14. **Also sold with SX1262** (the LilyGoLib page documents the SX1262 retail SKU as primary) — same board/pins, different defines (cheap 2nd env later) | +| Keyboard | Physical QWERTY via **TCA8418** I²C matrix controller (addr `0x34`), INT 6, backlight 46. Raw matrix events — keymap/shift/sym handled on our side | +| Encoder | Rotary A 40, B 41, **press 7** | +| Buttons | **BOOT = GPIO0** (usable as user button + sleep wake — matches both existing boards' `PIN_USER_BTN=0`). Physical power key is PMU QON, not a GPIO — can only wake the device (1s hold), never programmable | +| Power | **BQ25896** charger PMU (addr `0x6B`) + **BQ27220 fuel gauge** (addr `0x55`, battery % / mV over I²C — NOT an ADC divider). Battery: 3.7 V / 1500 mAh (5.55 Wh). DeepSleep ≈530 µA, LightSleep ≈2.26 mA, Power-off ≈26 µA | +| IO expander | **XL9555** (addr `0x20`): DRV_EN ch0, AMP_EN ch1, KB_RST ch2, LORA_EN ch3, GPS_EN ch4, NFC_EN ch5, **DISP_RST ch6 — the ST7796 panel's hardware reset** (missing from BOTH the LilyGoLib doc's channel table and arduino-esp32 master's pins header; confirmed from LilyGoLib's `LilyGo_LoRa_Pager.cpp` begin(), which pulses it LOW→50ms→HIGH before display init — leaving it floating was M7's intermittent-black-screen root cause, see `TLORA_PAGER_M7_HW_DEBUG_LOG.md` session 2), GPS_RST ch7, KB_EN ch8, GPIO_EN ch9, SD_DET ch10, SD_PULLEN ch11, SD_EN ch12 | +| GPS | u-blox **MIA-M10Q**: TX 12, RX 4, PPS 13 | +| I²C bus | SDA 3, SCL 2 — shared by TCA8418 (`0x34`), XL9555 (`0x20`), BQ25896 (`0x6B`), BQ27220 (`0x55`), PCF85063 RTC (`0x51`), BHI260AP IMU (`0x28`), DRV2605 haptics (`0x5A`), ES8311 codec (`0x18`) | +| SD | microSD on the shared SPI bus, CS 21, card-detect via expander (ch10), max 32 GB, **FAT32 only** | +| Audio | ES8311 codec (I²C `0x18`; I2S MCLK 10, SCK/BCK 11, WS 18, SDOUT 45, SDIN 17 — confirmed against arduino-esp32's canonical `variants/lilygo_tlora_pager/pins_arduino.h`, matching LilyGoLib's own `codec.setPins(I2S_MCLK, I2S_SCK, I2S_WS, I2S_SDOUT, I2S_SDIN)` call in `LilyGo_LoRa_Pager.cpp`) driving an **NS4150B** 3 W Class-D amp (enabled via expander AMP_EN ch1, toggled at runtime around playback — not the boot-time permanently-on drive; see Decision below) | +| Misc | PCF85063A RTC (INT 1), BHI260AP IMU (INT 8), ST25R3916 NFC (unused — CS 39, INT 5, powered via expander NFC_EN ch5), USB VID/PID `0x303A:0x82D4` | + +## Board JSON — VERIFIED ✅ (landed as `boards/lilygo-t-lora-pager.json`) + +The JSON (Meshtastic-lineage; byte-identical to trail-mate's copy) is **correct for +the LR1121 unit but radio-agnostic**: it describes only the S3 module (flash/PSRAM/ +USB id/CDC-on-boot), which every radio variant of the pager shares. The radio is +selected by our build flags (`RADIO_CLASS`/`WRAPPER_CLASS` + pins), same as the +existing boards. Notes: + +- `partitions: app3M_fat9M_16MB.csv` is just the default — we override with our own + OTA+tiles+spiffs csv via `board_build.partitions` (see worklist ①). +- `variant: lilygo_tlora_pager` + `variants_dir: variants` → needs + `variants/lilygo_tlora_pager/pins_arduino.h` (write our own; don't copy + trail-mate's — it brands `USB_PRODUCT "TRAIL MATE"`). +- `-DARDUINO_USB_MODE=1` (HW-CDC) is in the JSON's extra_flags. The T-Deck ships + MODE=1 fine; the Heltec V4 regressed on it (large companion frames dropped — + see the note in `platformio.ini`). **Verify the device-profile frame over USB + companion early** (worklist ⑦); if it drops bytes, switch to TinyUSB CDC like + the V4. + +## Decisions (architecture) + +1. **Normal PlatformIO env, T-Deck model** — NOT the Tanmatsu IDF-subproject route. + The pager is a plain ESP32-S3 Arduino target; it slots into `platformio.ini` + next to the existing two envs and into `release.sh`'s env list later. +2. **LR1121 wrappers vendored in the variant dir — zero core-fork churn for + bring-up.** The core fork (`core-v1.16.5`) only has `CustomLR1110*`. + **Re-verified 2026-07-06: upstream `meshcore-dev/MeshCore`'s `main` branch does + NOT currently have `CustomLR1121*` or a `variants/lilygo_tlora_pager/` dir** + (checked `src/helpers/radiolib/` — only `CustomLR1110{,Wrapper}.h` exists there + too; issue [meshcore-dev/MeshCore#861](https://github.com/meshcore-dev/MeshCore/issues/861) + "Support for LR1121" is still open). Earlier research that assumed an + upstream crib source was wrong or looked at a branch/fork that no longer + exists — **re-check upstream at Milestone ② time**, but plan for having to + author `CustomLR1121{,Wrapper}.h` ourselves by adapting the core fork's own + `CustomLR1110{,Wrapper}.h` pair (same RadioLib `LR11x0` family — swap the + base type from `LR1110` to `LR1121`), cross-checked against trail-mate's + `initLoRa()` (which drives RadioLib's stock `LR1121` class directly, no + custom wrapper) for the RF-switch table / `setTCXO` sequence. Since + `CustomLR1121{,Wrapper}.h` only subclass RadioLib's `LR1121` and the core's + `RadioLibWrapper` (both on the include path), they can live in + `variants/lilygo_tlora_pager/` for now and move into the fork at the next + `core-*` tag. Precedent: the Tanmatsu keeps its whole radio bridge in its + variant dir. + **Found at Milestone ④'s first real compile**: `CustomLR1121Wrapper.h`'s + bare quoted `#include "RadioLibWrappers.h"`/`"LR11x0Reset.h"` (copied + verbatim from `CustomLR1110Wrapper.h`'s shape) don't resolve — those work + in the core fork only because the file sits in the *same* + `src/helpers/radiolib/` directory as its targets (quote-include searches + the including file's own directory first); ours lives in the variant dir + instead. Fixed to angle-bracket `` includes, matching + `target.h`'s already-correct pattern for the same headers. +3. **LR1121 init is explicit** (no `std_init`): RF-switch table on **DIO5/DIO6** + (`STBY {L,L} / RX {L,H} / TX {H,L} / TX_HP {H,L}`) + **`setTCXO(3.0f)`** — + confirmed in both trail-mate (`boards/tlora_pager/src/tlora_pager_board.cpp`, + `initLoRa()`) and upstream. Sync word / preamble / CR come from the same + NodePrefs plumbing as the other boards so it interoperates with the mesh. +4. **Display = new `ST7796LCDDisplay` app-side** (in `src/helpers/ui/`), implementing + the core's `DisplayDriver` interface (`begin/width/height/startFrame/endFrame/ + setDisplayRotation/writePixelsRGB565`) — that's all the LVGL flush path uses. + TFT_eSPI has `ST7796_DRIVER` (`USER_SETUP_LOADED` + `-D` pin set). + **Correction, landed with Milestone ③**: "the Heltec V4 already builds on + TFT_eSPI, so mirror that wiring" was wrong — `ST7789LCDDisplay` (Heltec + V4's *and* T-Deck's display class, same file) is actually Adafruit_GFX/ + Adafruit_ST7789-based; Heltec V4's TFT_eSPI lib_dep/`-D` flags are + vestigial (nothing else in the repo `#include`s `TFT_eSPI.h`). This makes + `ST7796LCDDisplay` the first real TFT_eSPI consumer in this codebase — + every method call was verified directly against the pinned + `bodmer/TFT_eSPI @ ^2.5.43` source, not cribbed from the sibling class. + **Also found and mitigated**: this panel's 222px glass is narrower than + the ST7796 controller's 320px GRAM, requiring a 49px column/row offset + that TFT_eSPI only applies automatically when `-D CGRAM_OFFSET=1` is set — + missing from this decision's original flag list; a `#error` guard in + `ST7796LCDDisplay.cpp` now catches the omission at M4 compile time (see + worklist ③ for the full writeup). Backlight is the AW9364 (stepped pulse + dimming), not a plain GPIO PWM — wrapped in `ST7796LCDDisplay` itself using + `lewisxhe/SensorLib`'s `AW9364LedDriver` directly (a maintained library, + already an M1-established dependency) rather than a hand-rolled driver. +5. **Input = the Tanmatsu registration branch** (KEYPAD indev + `s_nav_group` only, + no pointer indev), gated by a new device cap. Rotary → nav FIFO; TCA8418 → + `handleHwKey()`. New pollable drivers in `src/helpers/input/` following the + existing style (begin/poll/read API, no LVGL inside the driver). +6. **Battery/power via libraries, not hand-rolled** (CONTRIBUTING rule): lewisxhe + **SensorLib** covers BQ27220 (`GaugeBQ27220`), XL9555 (`ExtensionIOXL9555`), + PCF85063, DRV2605; **XPowersLib** covers the BQ25896. `TLoraPagerBoard : + public ESP32Board` overrides `getBattMilliVolts()` (gauge query), + `getManufacturerName()`, power-rail bring-up in `begin()` (expander), and sleep. +7. **One codebase**: all pager-specific UI behavior rides existing/new `CAP_*` + flags in `src/ui-touch/device_caps.h` — no forked screens. +8. **`variants/lilygo_tlora_pager/` app glue and the board's Arduino "variant" + pin map had to split into two directories** — found only by actually + compiling in Milestone ④, not anticipated by any earlier decision. + PlatformIO's arduino-esp32 build script (`platformio-build.py`) always + compiles every source file under `board_build.variants_dir//` + as a separate `FrameworkArduinoVariant` library, in a build context with + none of our app's `lib_deps` include paths. T-Deck/Heltec V4 never hit this + because their board JSONs point `"variant"` at a variant the *framework* + already bundles (`esp32s3`, `heltec_v4`) — completely separate from our own + `variants/lilygo_tdeck/`/`variants/heltec_v4/` app-glue directories, zero + collision. The pager has no framework-bundled variant, so its board JSON + was forced to set `"variants_dir": "variants"`, pointing PlatformIO's + variant resolution at the exact same directory we'd used for + `TLoraPagerBoard.*`/`target.*`/`CustomLR1121*` — so the framework tried to + compile those too, and failed on missing `Wire.h`/`RadioLib.h`. Fixed by + moving `pins_arduino.h` alone into a new `variants/lilygo_tlora_pager_pins/` + folder and pointing the board JSON's `"variant"` there instead; our own + `TLoraPagerBoard.*`/`target.*`/`CustomLR1121*` stay in + `variants/lilygo_tlora_pager/`, reached only via our own + `build_src_filter`, exactly once. Confirmed safe against + `get_partition_table_csv()`'s similar `variants_dir`-based fallback logic + (moot here since `board_build.partitions` is set to a full explicit path, + which that function returns verbatim once its variant-relative lookup + attempts fail). + +9. **Notification sound: hand-rolled ES8311 driver, not a vendored codec HAL.** + `espressif/esp_codec_dev`'s ES8311 device (Apache-2.0, ~4.5k lines across + its generic multi-codec plugin architecture) is more than this + single-chip, playback-only board needs, and defaults to the *new* + `driver/i2s_std.h` API where this codebase's existing T-Deck sound code + uses the legacy `driver/i2s.h` (which already supports MCLK via + `i2s_pin_config_t.mck_io_num`, so no new API family was needed). Wrote + `variants/lilygo_tlora_pager/Es8311Codec.{h,cpp}` instead — ~200 lines, + DAC-only, register sequence and MCLK clock-divider coefficients derived + from Espressif's public `es8311.c`/`es8311_reg.h` (see `NOTICE`), fixed to + a 256x-sample-rate MCLK ratio covering the standard 8k-48k rates + `wavParse()` already accepts. Matches this port's own precedent (T-Deck's + sound code is similarly hand-rolled, ~200 lines) and the milestone rule + against introducing new abstraction layers. +10. **AMP_EN toggled around playback, not left permanently on.** The + boot-time `TLoraPagerBoard::begin()` drive of every expander rail + (including AMP_EN) exists for bus-integrity reasons specific to the boot + window (unpowered chips clamping the shared I²C bus) — unrelated to + audio and left untouched. + `TLoraPagerBoard::setAmpEnabled()` is the runtime toggle the sound code + brackets each chime/WAV with (amp off at idle), matching both the + vendor's own `codec.setPaPinCallback()` pattern and this port's existing + on-demand-resource philosophy (T-Deck installs/uninstalls its I2S driver + per chime for the same reason). Cost is one extra I²C write per + chime start/end on a bus already used for every other rail toggle. + +## Worklist (ordered, each step ends build-green for ALL envs) + +- [x] ⓪ Research + board JSON verification; land `boards/lilygo-t-lora-pager.json` + this tracker. +- [x] ① **Variant skeleton**: `variants/lilygo_tlora_pager/{pins_arduino.h, TLoraPagerBoard.h/.cpp}` + `partitions_tlora_pager_touch.csv` (T-Deck's OTA/tiles/spiffs/coredump layout, byte-identical offsets). Board class (`TLoraPagerBoard : public ESP32Board`): `begin()` re-inits Wire on SDA3/SCL2, probes the XL9555 expander (addr 0x20) and enables LORA_EN/GPS_EN/KB_EN/KB_RST/SD_EN rails, probes the BQ27220 gauge, handles the deep-sleep RX-packet wake reason (mirrors `TDeckBoard.cpp`). `getBattMilliVolts()` reads the gauge (`refresh()` + `getVoltage()`), falling back to 3700 mV if the probe/refresh fails. `getManufacturerName()` → "LilyGo T-LoRa Pager". `enterDeepSleep()` mirrors `TDeckBoard.h`. BQ25896 charger deliberately left as a TODO (gauge alone covers the UI). `target.{h,cpp}` deferred to ② — nothing references the new files yet, so both shipping envs build unchanged (verified green). +- [x] ② **Radio**: `variants/lilygo_tlora_pager/{CustomLR1121.h, CustomLR1121Wrapper.h, target.h, target.cpp}`. + `CustomLR1121{,Wrapper}.h` authored by adapting the core fork's own + `CustomLR1110{,Wrapper}.h` (LR1110/LR1121 share RadioLib's `LR11x0` base — + confirmed by reading RadioLib 7.6.0 source directly: same protected + `freqMHz`/`spreadingFactor` members, same `getIrqStatus()`/`getRssiInst()` + inherited from `LRxxxx`/`LR11x0`), since upstream has nothing to crib (see + Decision ②). `radio_init()` in `target.cpp`: `spi.begin(...)` → + `radio.begin(LORA_FREQ, LORA_BW, LORA_SF, cr, ..._SYNC_WORD_PRIVATE, + LORA_TX_POWER, 8, 3.0f)` → `setRfSwitchTable(DIO5/DIO6)` → `setCRC(1)`. + **Three deliberate deviations from trail-mate, found by reading RadioLib's + actual source rather than copying its call sequence — see Decision ② for + the full reasoning:** + 1. No explicit `radio.reset()` before `begin()` — RadioLib's + `LR11x0::modSetup()`→`findChip()` already resets the chip internally + (with retries); trail-mate's explicit reset is redundant, not wrong. + 2. No second `radio.setTCXO(3.0f)` call after `begin()` — trail-mate only + needs that because its `initLoRa()` calls the *zero-arg* `begin()` + (default `tcxoVoltage=1.6V`) and fixes it up after. Our `begin()` passes + `3.0f` as the 8th arg directly, which `LR11x0::modSetup()` already + applies internally — a second call would be a no-op. + 3. Added `radio.setCRC(1)` after `begin()` (LR11x0's `begin()` defaults to a + 2-byte CRC) to match the 1-byte CRC every other MeshCore radio wrapper + uses for wire-protocol interop (`CustomSX1262::std_init()` makes the + identical override) — trail-mate never needed this since its app isn't + interoperating with MeshCore's own framing. + Also **not** calling `rtc_clock.begin(Wire)` in `radio_init()` — see the new + Risk item below (RTC address collision). `RfSwitchMode_t`/`OpMode_t`/DIO5-6 + constants verified to exist with the expected shape directly in the pinned + `jgromes/RadioLib @ ^7.6.0` source (not just trusted from trail-mate). + Env defines for M4 to use: `RADIO_CLASS=CustomLR1121`, + `WRAPPER_CLASS=CustomLR1121Wrapper`, `P_LORA_NSS=36 / _RESET=47 / _BUSY=48 / + _DIO_1=14`, SPI 35/34/33, **`PIN_GPS_RX=12` / `PIN_GPS_TX=4`** (see the GPS + risk item below — these are swapped relative to trail-mate's raw + `GPS_RX`/`GPS_TX` macro values, on purpose), `GPS_BAUD_RATE=38400`. Keep the + standard `RADIOLIB_EXCLUDE_*` set (LR11X0 stays IN; can also exclude SX126X + here). +- [x] ③ **Display**: `src/helpers/ui/ST7796LCDDisplay.{h,cpp}` on TFT_eSPI + (`ST7796_DRIVER`, `TFT_WIDTH=222`, `TFT_HEIGHT=480`, MADCTL rotation for + landscape). This is the **first real TFT_eSPI consumer in this codebase** — + `ST7789LCDDisplay` (the sibling this was "modeled on") turned out to be + Adafruit_GFX/Adafruit_ST7789-based for both Heltec V4 and T-Deck; the + Heltec V4 env's TFT_eSPI lib_dep/`-D` flags are vestigial (nothing + `#include`s `TFT_eSPI.h` anywhere else in the repo). Only the + `DisplayDriver`-satisfying shape was mirrored, not any API calls — every + TFT_eSPI method used (`init`, `setRotation`, `setAttribute`/`CP437_SWITCH`, + `textWidth`, `pushColors`, `writecommand`/`TFT_DISPOFF`, `fillScreen`, + `fillRect`/`drawRect`/`drawPixel`, `setAddrWindow`/`startWrite`/`endWrite`) + was verified directly against the pinned `bodmer/TFT_eSPI @ ^2.5.43` source + in `.pio/libdeps/*/TFT_eSPI/TFT_eSPI.h`, not assumed. + **Found and fixed a real correctness bug in the same pass**: this panel's + 222px glass is narrower than the ST7796 controller's 320px GRAM (trail-mate + applies explicit 49px column/row offsets — confirmed the source of the + magic number: `320 - 222 = 98`, halved/centered = 49px each side). TFT_eSPI's + own `ST7796_Rotation.h` already has this exact fix (`colstart=49`/ + `rowstart=49` depending on rotation, applied automatically inside every + `setAddrWindow()` call) — but only when `CGRAM_OFFSET` is `#define`d. + Unlike `ST7789_Defines.h` (which self-defines it), `ST7796_Defines.h` does + not, and it was **missing from this milestone's own drafted M4 flag list**. + Without it the build compiles clean but every frame renders shifted/cropped + by 49px with no error — added a `#error` guard in the new `.cpp` that fires + the moment M4 compiles this file without the flag, with the fix spelled out + in the message. **M4 must add `-D CGRAM_OFFSET=1`.** + Backlight: AW9364 stepped pulse-dimmer, wrapped directly in + `ST7796LCDDisplay` (no separate `Aw9364Backlight.{h,cpp}` — `ST7789LCDDisplay` + turned out to have no brightness hook to mirror at all; brightness on the + other two boards is a `UITask.cpp`-owned free function doing raw LEDC PWM + on `PIN_TFT_LEDA_CTL`, which the AW9364's pulse protocol can't use). + Consumes `lewisxhe/SensorLib`'s `AW9364LedDriver` directly (header-only, + already vendored via the M1-established SensorLib dependency) rather than + hand-rolling the pulse timing the milestone doc originally suggested. + Exposes `setBrightness(uint8_t pct)`/`getBrightness()` (0-100, matching the + Settings UI's existing convention) so Milestone 6 can wire it in with one + line. **M6 needs a new branch ahead of `UITask.cpp`'s existing + `PIN_TFT_LEDA_CTL` PWM branch** — once M4 defines that macro for the pager, + the existing LEDC-PWM code would also compile and fight the AW9364's pulse + protocol (a duty cycle is not a valid input to this chip). `NOTICE` updated: + added a `SensorLib` entry (missing since M1) and corrected the `TFT_eSPI`/ + `Adafruit GFX` lines' backend descriptions (both were wrong about which + boards use which library). `DISPLAY_CLASS=ST7796LCDDisplay`. Gate: both + shipping envs build unchanged (neither's `build_src_filter` references + `helpers/ui/*.cpp` yet, so the new TU isn't even parsed by either compiler + today) — full compiler verification of the new code waits for M4. +- [x] ④ **Env**: `[env:tlora_pager_lr1121_companion_radio_touch]` in + `platformio.ini`, cloned from the T-Deck env with board/radio/display/GPS/ + input deltas (see Decisions ①-④, ⑥-⑧ for exact values and reasoning). Added + lib_deps: `bodmer/TFT_eSPI @ ^2.5.43`, `adafruit/Adafruit TCA8418 @ ^1.0.2`, + `lewisxhe/SensorLib @ 0.3.3` (exact version trail-mate proves works, not + guessed) — **dropped** `adafruit/Adafruit ST7735 and ST7789 Library` from + the cloned list (T-Deck/Heltec's Adafruit display backend; unused here, + TFT_eSPI replaces it). No `XPowersLib` (M1 left the BQ25896 charger out of + scope). `default_envs` left unchanged (still just the two shipping boards). + Also fixed two structural bugs found only by actually compiling (see + Decision ⑧) and added one forced, minimal `UITask.cpp` `#elif + defined(TLORA_PAGER)` arm (display-class typedef only — see the Status + line). **Compile gate: all three envs build green** — verified. +- [x] ⑤ **Input drivers**: `src/helpers/input/{PagerKeyboard,PagerEncoder}.{h,cpp}`. + Gated `HAS_PAGER_KEYBOARD`/`HAS_PAGER_ENCODER`, already reached by every + env's `+` filter — no `platformio.ini` change needed + for compilation, only the pin flags (`KB_INT=6`, `KB_BACKLIGHT=46`, + `ROTARY_A=40`, `ROTARY_B=41`, `ROTARY_C=7`, matching the repo's + explicit-`-D`-alongside-`.cpp`-fallback convention). + **`PagerKeyboard`**: T-Deck's own keyboard driver turned out to be the + wrong shape to mirror directly — its C3 co-processor resolves ASCII itself + over I2C, so `TDeckKeyboard.cpp` never sees a raw matrix event. The + TCA8418 (Adafruit_TCA8418 lib, already an M4 dependency) reports raw + row/col events instead, so the keymap + shift/sym/alt state machine lives + in `PagerKeyboard.cpp` itself. Reused trail-mate's `LilyGoKeyboard` keymap + tables verbatim (same physical PCB) — `keymap[4][10]`/`symbol_map[4][10]`, + Alt as a hold-to-symbol-layer modifier (no separate physical Symbol key on + this hardware), Backspace special-cased to `'\b'` — UX choices trail-mate + already field-validated, reused rather than re-derived. **The Shift/Caps + key's behavior changed 2026-07-13** from trail-mate's original + press-to-toggle case lock to conventional keyboard semantics: held alone + it's a momentary Shift (uppercases whatever letters are typed while held, + releases back to normal); held Alt + a Shift press instead chords. `kCapsPos` + renamed `kShiftPos` to match. **Chord effect changed again 2026-07-14**: + `PagerKeyboard.cpp` no longer decides what Alt+Shift *does* (it has no UI + visibility) — it only reports the chord via + `pagerKeyboardConsumeAltShiftChord()`; `UITask.cpp`'s + `updatePagerAltShiftChord()` toggles persistent Caps Lock ONLY while a text + field is actually being edited (same "ta" derivation as `handleHwKey()`'s + TLORA_PAGER branch), and jumps straight Home (`navGoToMainTab`) everywhere + else — toggling Caps Lock with no field to see it change in was reported as + surprising/purposeless outside of typing. + See `PagerKeyboard.cpp`'s matrix-legend comment for the one documented + edge case (Alt+Shift+O or Alt+Shift+L all three held phantom-ghosts a + stray 'q'/'a' — a diode-less-matrix limitation, not a bug, and not the + intended gesture). Ring-buffer/SPSC/threading-contract shape + mirrors `TDeckKeyboard.cpp`'s (single poll context, UI-thread-safe + `readKey()`) even though the underlying hardware access differs completely. + Implemented as straightforward polling (`available()`/`getEvent()` drained + every `pagerKeyboardPoll()` call), not INT-pin-gated, despite the milestone + doc's "prefer INT-driven drain" suggestion — trail-mate's own INT handling + does extra `INT_STAT`/`GPIO_INT_STAT` register bookkeeping whose exact + clear-vs-latch semantics weren't independently verified, and an ISR-gated + poll that's wrong would present as "keyboard stops after first keypress" — + a regression only caught on hardware (M7). Polling is explicitly sanctioned + as a fallback by the milestone doc and carries no such risk; INT-driven + draining is a valid future optimization once verified on real hardware. + **`PagerEncoder`**: the milestone's "ISR edge-counting exactly like + `TDeckTrackball.cpp:27-47`" doesn't transfer literally — the T-Deck + trackball is 4 independent direction-pulse GPIOs (no direction logic + needed, each pin already means one direction), not a true A/B quadrature + pair, so it can't answer "which way did it turn." Implemented a standard + Gray-code quadrature transition table instead (both edges of both A and B + feed one ISR, table lookup yields +1/-1/0 per transition) — same + ISR-does-cheap-arithmetic-only shape and `noInterrupts()`-snapshot read + pattern as `TDeckTrackball.cpp`, just the right decode logic for a genuine + quadrature signal. Divides by `PAGER_ENCODER_STEPS_PER_DETENT` (default 4, + the common EC11-style ratio) to convert raw transitions to detents, + carrying the remainder forward across reads — **this divisor is an + unverified assumption, confirm on hardware in M7**. + **Gate:** all three envs build — verified. +- [x] ⑥ **UITask wiring**: `device_caps.h` got its `TLORA_PAGER` cap block (no + touch, no rotate, no large-screen, GPS 1, OTA 1, lock-screen 1; `CAP_SD`/ + `CAP_FILESYSTEM` corrected to 0 — see below) plus widened `CAP_KEYBOARD`/ + `CAP_KEYPAD_NAV` derivations to recognize `HAS_PAGER_KEYBOARD`/ + `TLORA_PAGER`. `UITask.cpp` changes, all pager-gated: KEYPAD indev + registered by widening the Tanmatsu branch's `#if` to + `defined(HAS_TANMATSU) || defined(TLORA_PAGER)` (~35706) — pager reuses + `tanmatsuKeypadRead`'s plumbing but not `bsp_input_get_queue`, which stays + Tanmatsu-only; forced `s_ui_rotation = LV_DISP_ROT_270` and a new + `hor_res=480/ver_res=222` branch (~35626); a new `updatePagerEncoder()` + (delta → `navPushTap(NEXT/PREV)`, click → ENTER, long-press ≥1000ms → + ESC); main-loop drain wired for both `pagerKeyboardPoll()`/ + `pagerKeyboardReadKey()` → `handleHwKey()` and the encoder function above; + `applyBrightness()`/`touchScreenBacklight()` got pager-first branches + calling `display.setBrightness()` instead of falling into the + `PIN_TFT_LEDA_CTL` PWM branch (closes risk 1f — PWM would never have + worked on the AW9364's discrete-pulse interface). Draw buffer already + sized off `hor_res` so needed no separate edit. 222-px vertical audit + deferred to on-device measurement (M8) rather than guessed constants — see + risk 8 below. Final sizes: pager RAM 23.8% (78088/327680 B), Flash 66.4% + (2696465/4063232 B); Heltec V4/T-Deck unchanged at 25.3%/73.5% (byte- + identical to pre-M6, confirming the gate on every edit). Three corrections + vs. the milestone doc's assumptions, all found by compiling rather than + assumed — see risks 1h/1i/1j: + - `CAP_SD`/`CAP_FILESYSTEM` looked right from the hardware (real microSD + slot) but the actual mount code is hardcoded to `HAS_TDECK_GT911`, not + truly `CAP_SD`-generic — set back to 0, real support is unscheduled + follow-up. + - `navMaybeRebuild()` (populates the LVGL focus group every screen) wasn't + reachable on the pager's own cap combination — added a + `#elif defined(TLORA_PAGER)` arm; without it the KEYPAD indev would have + had a permanently empty focus group. + - Several T-Deck-keyboard-gated helpers (`handleHwKey`, `isDismissKey`, + `tabForKey`, `navMenubarKeysSync`) live inside multiply "paused and + reopened" `#if defined(HAS_TDECK_KEYBOARD)` regions; widened each + reopen's gate individually (tracing real nesting depth, not just grep + hits) rather than touching the whole file, and kept genuinely + T-Deck-only sub-logic (the p/q/a dismiss-key mapping, which would eat + normal QWERTY typing on the pager) under its own narrower, unwidened gate. +- [x] ⑦ **Headless bring-up gate, UI portion** (needs hardware) — DONE + (2026-07-06/07 sessions, see `TLORA_PAGER_M7_HW_DEBUG_LOG.md`): flash + + serial recipes established; boot clean; SPIFFS mounts; display + boot logo + + full UI + keyboard nav + encoder verified on the glass (after 6 real bugs — + stale-NVS BLE bonds, screen-wake path, TFT_eSPI S3 SPI-port index, panel + INVON, two-SPI-hosts pin theft, and the floating XL9555-ch6 panel reset). +- [~] ⑦b **Radio/USB/SD bring-up gates** (needs hardware + second mesh node) — + **radio joins the live mesh: DONE**, adverts/DMs confirmed reliable both + directions against a second node after the TX-power fix (risk 10, + retested 2026-07-11) — still watch upstream LR1121 ACK issue + meshcore-dev/MeshCore#1376 for anything beyond basic reliability. USB + companion link passing the large device-profile frame (see HW-CDC note + above): still untested. **SD storage: DONE** — real mount, browse, + read/write/delete, and insert/remove detection all confirmed on hardware + (risk 1h). (DataStore/NVS-level SD use — routing contacts/channels to the + card — is a separate, still-deferred feature, not part of this bring-up + gate.) Remove the temporary `[DISP]` register readback in + `ST7796LCDDisplay::begin()` once the USB piece passes. +- [~] ⑧ **On-device UI pass** — ACTIVE: nav-coverage audit screen by screen (every interactive control reachable via focus group — the Tanmatsu work paved this), chat layout at 222 px, map pan via encoder/keys, fonts legibility at 480-wide, plus fixing any weird UI/keyboard behaviors surfaced by manual testing (human provides photos/repro steps). +- [ ] ⑨ **Release pipeline**: add the env:binname pair to `release.sh` `ENVS`, flasher manifest (`deploy/flasher/manifest-tlora-pager.json`), OTA env name via `FIRMWARE_OTA_ENV`. Separate PR. +- [x] ⑩ (Optional, cheap) `tlora_pager_sx1262_...` env for SX1262-variant owners — **DONE, hardware-verified 2026-07-13** (`tlora_pager_sx1262_companion_radio_touch`, all 4 envs green; see `TLORA_PAGER_PORT_MILESTONES.md`'s Milestone 10 for the full writeup, including the `target.h` missing-include bug found + fixed). Radio gate (M7 step 3) confirmed on real hardware: user sent + received messages on public, a custom `#` channel, and DM. + +## UI-changes inventory (what actually changes in `src/ui-touch/`) + +| Area | Change | Size | Status | +|---|---|---|---| +| Indev registration (~35706) | Widened the Tanmatsu keypad-only branch's gate to `\|\| defined(TLORA_PAGER)`; no pointer indev registered | small | done | +| Input drain (main loop ~37350) | Pager branch: `pagerKeyboardPoll()`+`pagerKeyboardReadKey()`→`handleHwKey()`; `updatePagerEncoder()`→`navPushTap(NEXT/PREV/ENTER/ESC)` | small | done | +| `handleHwKey()` (~27990) and its multiply-reopened `HAS_TDECK_KEYBOARD` regions | Widened each reopen's gate individually to admit `HAS_PAGER_KEYBOARD`; kept the T-Deck-only p/q/a dismiss mapping under its own narrower gate | small | done | +| Resolution block (~35604–35650) | New branch: forced `LV_DISP_ROT_270`, `hor_res=480/ver_res=222` | small | done | +| Draw buffer (~1399) | Sized off `hor_res`, no separate edit needed | trivial | done (no-op) | +| Brightness (`applyBrightness`~28906, `touchScreenBacklight`~36511) | New pager-first branches calling `display.setBrightness()` instead of the PWM `PIN_TFT_LEDA_CTL` path | small | done | +| `navMaybeRebuild()` reachability | Added `#elif defined(TLORA_PAGER)` arm — was unreachable, would've left focus group permanently empty | small | done (bug fix) | +| Vertical budget | Audit `STATUSBAR_H`, `TABBAR_H`, `CHAT_KB_H`, modal/chat height helpers for 222 px | **the real work** | deferred to M8 on-device (risk 8) | +| Focus-nav coverage | Screen-by-screen pass that every control is in `s_nav_group` | medium, on-device | deferred to M8 | +| device_caps.h | New `CAP_*` block: no touch, hw keyboard, encoder, 480×222, GPS, OTA, lock-screen; SD/filesystem left 0 by design — real SD support added via widened individual call sites instead (see risk 1h) | trivial | done | +| Keyboard backlight (`pagerKeyboardSetBacklight()`) | `updatePagerKbBacklight()` tick + Settings → Keyboard cycle row (off/on/auto) | small | done (risk 9) | +| microSD (file manager + WAV picker) | `fmSdTryMount()`/`fmShowRoots()`/`fmIsAudio()` etc. widened to `\|\| defined(TLORA_PAGER)`; DataStore SD routing, chat-history cap, tile/wallpaper/backup SD use still deferred | medium | done, scoped (risk 1h) | +| Chat bubble Enter/Backspace (`handleHwKey()` ~28808) | Enter on a focused bubble opens the Ack/Mention/Copy/Info/Block menu (`navEnterBubble()`); Backspace jumps to the newest message when the scroll-to-bottom button is showing | small | done | +| Shift/Caps (`PagerKeyboard.cpp`) | Held Shift = momentary uppercase; held Alt+Shift = chord (see below for what it does) | small | done | +| Accent-variant popup keyboard nav | Fn+Space arms it; encoder walks variants; encoder click/Enter confirms; Backspace cancels (`s_accentnav_active`/`accentNavRestyle()`/`accentNavConfirm()`) | medium | done | +| @-mention picker keyboard nav | Auto-arms the instant the list appears (no Fn+Space needed); same encoder walk/confirm/cancel as the accent popup (`s_mentionnav_active`/`mentionNavRestyle()`/`mentionNavConfirm()`) | medium | done | +| Encoder short click on a focused bubble | Mirrors keyboard Enter — opens the same action menu instead of a plain ENTER a bubble doesn't react to | small | done | +| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; jumps to Home (`navGoToMainTab`) everywhere else — `PagerKeyboard.cpp` only reports the chord, `UITask.cpp` decides the effect | small | done | +| Map pan (WASD) + slider nudge (Q/E) | WASD pans the Map tab (`mapNudge()`, shared with Tanmatsu's Ctrl+Arrow); Q/E adjust a focused slider (moved off D/F to avoid colliding with WASD) | small | done | +| App-drawer tile focus highlight | `NAV_ACCENTFOCUS_FLAG` makes `navFocusCb` use the tile's own accent tint instead of the generic white reverse-fill, on every board | small | done | +| SX1262-variant env | `tlora_pager_sx1262_companion_radio_touch` — same board/pins, `CustomSX1262`/`CustomSX1262Wrapper` instead of the LR1121 classes | medium | done (worklist ⑩) | + +Everything else (map, chat, contacts, channels, settings, companion protocol, +MQTT, OTA) is resolution-agnostic or already keyed off caps. + +## Risks / open questions + +1. **LR1121 ACK/TX reliability** — upstream issue meshcore-dev/MeshCore#1376 reports + ACK problems on the pager's LR1121 (confirmed still open). Track it; our wrapper + crib should include any upstream fix. Gate ⑦ tests this explicitly. +1b. **No upstream `CustomLR1121` crib source exists yet** (re-verified + 2026-07-06 — see Decision ②'s caveat). Milestone ② needs to author the + wrapper by adapting the core fork's `CustomLR1110{,Wrapper}.h`, not by + copying an upstream file. Re-check upstream first in case it lands before + we get there — would save the work. +1c. **GPS `PIN_GPS_RX`/`PIN_GPS_TX` are named from the GPS module's + perspective in wadamesh's own core (`EnvironmentSensorManager.cpp` calls + `Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX)`, and `HardwareSerial::setPins()` + takes `(rxPin, txPin)` — so `PIN_GPS_TX` supplies the ESP's own **RX** pin, + `PIN_GPS_RX` supplies the ESP's own **TX** pin). trail-mate's/the canonical + arduino-esp32 `GPS_RX=4`/`GPS_TX=12` macros are named the OPPOSITE way — its + own `Serial1.begin(baud, cfg, GPS_RX, GPS_TX)` call uses + `HardwareSerial::begin()`'s `(rxPin, txPin)` order directly, so there + `GPS_RX` IS the ESP's own RX pin. **Net result: wadamesh's `PIN_GPS_RX` must + be set to `12` and `PIN_GPS_TX` to `4`** for M4 — the raw trail-mate values + swapped, not copied verbatim. Verified by reading both projects' actual + `Serial1.setPins()`/`begin()` call sites and the ESP32 core's + `HardwareSerial::setPins()`/`begin()` signatures directly, not by trusting + either project's macro names at face value. Baud confirmed at 38400 (same + MIA-M10Q as T-Deck Plus). +1d. **RTC auto-discovery would misread this board's real RTC.** + `AutoDiscoverRTCClock` (core, shared by all boards) only recognizes DS3231 + (`0x68`), RV3028 (`0x52`), and PCF8563 (`0x51`) — its probe is a bare I2C ACK + check. This board's PCF85063A sits at that same `0x51` address but has a + different register layout (RTClib's `RTC_PCF8563` driver would misread its + registers), so calling `rtc_clock.begin(Wire)` would silently produce + garbage timestamps instead of a clean fallback. `target.cpp`'s `radio_init()` + deliberately skips that call — same time behavior as T-Deck/Heltec V4 (ESP32 + software clock) rather than a false "RTC found" that's actually wrong. Real + PCF85063A support (SensorLib's `SensorPCF85063`) is unscheduled follow-up + work, not part of any milestone ①–⑩ yet. +1e. **`-D CGRAM_OFFSET=1` must land in Milestone ④'s pager env flags.** + This panel's 222px glass is narrower than the ST7796 controller's 320px + GRAM; TFT_eSPI's `ST7796_Rotation.h` only applies the required 49px + column/row offset when this flag is set (unlike `ST7789_Defines.h`, which + self-defines it). Without it, the pager build compiles clean but every + frame renders shifted/cropped by 49px with no error. `ST7796LCDDisplay.cpp` + has a `#error` guard that will catch the omission the moment M4 compiles + this file — but the actual fix belongs in `platformio.ini`, and it's easy + to miss since the build "succeeds." +1f. **M6 must add a pager branch to `UITask.cpp`'s brightness code ahead of + its existing `PIN_TFT_LEDA_CTL` PWM branch.** Milestone ③ reused that + macro name for the pager's AW9364 enable pin (naming consistency across + boards), but `UITask.cpp:~28868`'s `#if defined(PIN_TFT_LEDA_CTL) && + (PIN_TFT_LEDA_CTL >= 0)` branch drives that pin with 20kHz LEDC PWM — which + the AW9364 does not accept (it needs discrete edge pulses per step, not a + duty cycle). Once M4 defines `PIN_TFT_LEDA_CTL=42` for the pager, that + existing branch will compile and run for it too unless M6 adds a + `#if defined(TLORA_PAGER)` branch first that calls + `display.setBrightness(pct)` instead (the hook `ST7796LCDDisplay` already + exposes for exactly this). +1g. **RX-boosted-gain silently doesn't apply for the pager — RESOLVED + 2026-07-07 (commit `b3c6388`).** Was deferred, not fixed, in Milestone ④ + (matches M3's shared-file-edit precedent): with `-D USE_LR1121=1` defined + (not `USE_SX1262`/`USE_SX1268`), three call sites defaulted/applied this + radio setting only for the SX126x macros even though `CustomLR1121Wrapper` + already implements `setRxBoostedGainMode`/`getRxBoostedGainMode`: + `src/MyMesh.cpp` ~2540 (default pref value on first boot), ~2556 and ~3355 + (actually calling `radio_driver.setRxBoostedGainMode(...)`), and + `src/DataStore.cpp` ~264 (default pref value). Fixed by adding + `|| defined(USE_LR1121)` to all three conditions — safe/additive, zero + behavior change for T-Deck/Heltec V4, verified by all-three-envs build. + (A fourth, core-lib-only gate still exists in `CommonCLI.cpp`'s + `radio.rxgain` companion CLI command, checking `USE_SX1262`/`USE_SX1268`/ + `USE_LR1110` — left alone in this pass since it's core-lib, not app code; + now that the three UI/prefs call sites actually apply the setting, adding + `USE_LR1121` there too would be safe follow-up, just unscheduled.) +2. **TCA8418 keymap** — raw matrix + our own shift/sym/alt state machine; the T-Deck + never needed this (its C3 resolves ASCII). Bounded: trail-mate's layout tables are + a working reference. Landed in M5 as `src/helpers/input/PagerKeyboard.cpp`, + polling-based (not INT-pin-gated — see worklist ⑤ for why). +2a. **Encoder detent scaling — RESOLVED on hardware (2026-07-07).** The + assumed 4-transitions-per-detent (`PAGER_ENCODER_STEPS_PER_DETENT`) + matches this part: user drove the menus by encoder and reported it feels + right ("encoder is looking good"), no fractional/multiple stepping. + **Direction was also wrong, found and fixed later the same day**: unlike + scaling, the CW/CCW sign was never actually validated against physical + rotation — `kQuadTable`'s convention didn't match this part's wiring, so + turning the way a user expects to move forward/down moved focus and + page-scroll backward/up instead. Fixed by negating once in + `PagerEncoder.cpp`'s `pagerEncoderReadDelta()` (the driver, not UI code) + so every consumer inherits the corrected sense from one place. +1h. **`CAP_SD`/`CAP_FILESYSTEM` are 0 for the pager despite real microSD + hardware — a latent gap in `device_caps.h`'s own abstraction, not a pager + bug.** **File manager browsing + WAV notification-sound picker RESOLVED + 2026-07-10** (commits `8c7fb6a`/`1514e76`), scoped deliberately: `CAP_SD` + turned out to gate ~37 call sites (file manager, WAV picker, DataStore + contacts/channels routing, tile-cache SD fallback, wallpaper scanning, + settings backup, screenshots), so flipping the macro itself would have + silently turned on ~30 unvetted, T-Deck-only-tested features at once. + Instead `CAP_SD`/`CAP_FILESYSTEM` stay 0 by design, and only the ~7 + specific call sites needed for browsing + WAV picking were widened with + `|| defined(TLORA_PAGER)` — see those commits for the full categorized + site list. Added `pagerSdCardPresent()` (XL9555 `PAGER_EXPAND_SD_DET`, + confirmed-inverted polarity) and a pager `sdSharedSPI()` (reuses + `TFT_eSPI::getSPIinstance()`, the same bus the radio already shares). + Format support is explicitly out of scope (no `f_mkfs`/`sd_diskio.h` pulled + in) — the pager's SD row is tap-to-open only. Hardware-verified: mount, + browse, read/write/delete, insert/remove detection, and picking a WAV from + SD as a notification sound all confirmed on real hardware. **Still not + done** (unscheduled follow-up, deliberately deferred): DataStore + contacts/channels routing to SD (`setSecondaryFS`, what Tanmatsu/T-Deck + do to dodge SPIFFS GC stalls), the `MAX_UI_MESSAGES_SD` chat-history cap + bump, map-tile SD fallback/offline tile packs, lock-screen wallpaper SD + scanning, and settings/telemetry backup export-import to SD. +1i. **`navMaybeRebuild()` was unreachable for the pager in the first M6 + pass.** It's only called under `#if defined(HAS_TANMATSU) ... #elif + CAP_TRACKBALL ...`, neither of which the pager's cap combination matches + (`CAP_TRACKBALL=0`, not `HAS_TANMATSU`). Left alone, the KEYPAD indev + would register successfully but its focus group would stay permanently + empty — total, silent navigation dead-end, easy to miss since the build + still succeeds. Fixed with a `#elif defined(TLORA_PAGER)` arm alongside + the existing branches. +1j. **`isDismissKey()`'s T-Deck p/q/a mapping doesn't transfer to the + pager's full QWERTY** — that mapping exists only because the T-Deck's + sparse keyboard lacks a dedicated Esc key; reusing it verbatim for the + pager would make it impossible to type those three letters normally. + Returns `false` for the pager instead — its Esc equivalent is the rotary + encoder's long-press, handled separately in `updatePagerEncoder()`. +3. **222-px chat screen** — tightest layout wadamesh has shipped (current min is + 240). Mitigations: no on-screen keyboard (physical QWERTY), slimmer status/tab + bars, landscape chat already exists (320×240 path). +4. **HW-CDC companion frames** (`ARDUINO_USB_MODE=1`) — known-regressed on the V4, + fine on the T-Deck. Test the big device-profile frame first thing on hardware. +5. **Encoder-only ergonomics** on long lists (contacts @ 2000 max) — NEXT/PREV focus + walk may need page-jump keys from the QWERTY (cheap: map to `navMoveDir`). +6. **Shared SPI contention** (display flush vs radio IRQ vs SD) — same topology the + T-Deck ships, so expected fine via SPI transactions + CS discipline; keep an eye + on SD-write + RX overlap during history flush. **Partially de-risked in M7:** + TFT_eSPI force-defines `SUPPORT_TRANSACTIONS` on ESP32 (its raw-register fast + path still takes the SPI HAL mutex), RadioLib does no SPI from ISRs, and mesh + + UI loops share one task — so there is NO radio/display race (disproven theory, + don't re-chase; see the debug log). CS discipline is now enforced at boot: + `TLoraPagerBoard::begin()` parks LORA_NSS/LORA_RST/SD_CS/NFC_CS OUTPUT-HIGH + before any bus traffic (LilyGoLib's `initShareSPIPins()` equivalent). +7. **NVS-preserving flash chain** applies here too — 4-component flash, never the + merged image (wipes saved Wi-Fi creds). +8. **222-px vertical layout not yet audited against real constants** — + `STATUSBAR_H`/`TABBAR_H`/`CHAT_KB_H` and the modal/chat height helpers were + deliberately left untouched in M6 rather than guessing slimmer values + desk-side; the 480×222 branch compiles and boots (verified by the wordmark + centering math already being generic), but whether every screen actually + fits without clipping/overlap at 222 px tall can only be judged on real + hardware. Do this first in M8, before the broader nav-coverage pass. +9. **Keyboard backlight not wired to any timer/UI — RESOLVED 2026-07-10 + (commit `c9033a3`).** `pagerKeyboardSetBacklight()` (M5) worked standalone + but nothing called it. Fixed with a new `updatePagerKbBacklight()` per-tick + apply step (simple on/off at full LEDC duty — no brightness curve, unlike + the T-Deck's dimmable slider) plus a "Keyboard backlight" cycle row + (off/on/auto) in Settings → Keyboard. Also fixed auto mode never actually + responding to input in the same commit: neither `handleHwKey()` nor + `updatePagerEncoder()` called `noteKbActivity()` (the keyboard-backlight + idle timer), only the separate `noteUserInput()` (screen idle timer) — both + now stamp it. Hardware-verified. +10. **TX power default — RESOLVED 2026-07-07 (commit `b3c6388`).** Was + conservatively set to `LORA_TX_POWER=10` with no way to raise it (unlike + the T-Deck, which boots at its chip's real ceiling), following the same + "conservative default, tune on hardware later" note as risk 1g. Found via + a real DM test against a second mesh node: adverts/DMs sent FROM the + pager were unreliable while inbound reception was fine — the far node's + own TX power was unaffected, an asymmetric-range signature pointing + straight at the pager's own TX being too weak (10dBm is ~16x less power + than 22dBm). Fixed to `LORA_TX_POWER=22`, matching the T-Deck's pattern. + 22dBm confirmed as a real, safe ceiling for this chip below 1GHz — not a + guess carried over from the sibling boards — via RadioLib's pinned + `LR1120::checkOutputPower()` source (LR1121 inherits it: the HP PA + auto-selects above 14dBm, valid range -9..22) and cross-checked against + trail-mate's own working `TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22` for this + exact board. **Caveat**: a device already flashed before this fix keeps + its saved `tx_power_dbm=10` preference — the new compiled default only + applies to a fresh/factory-reset device; raise it manually in + Settings → Radio & Mesh on units that predate this commit. + **Retested against a second mesh node 2026-07-11: confirmed reliable** + (adverts/DMs both directions) — the fix holds, not just a theoretical + RadioLib-ceiling argument. +11. **Keyboard/encoder-driven scroll-into-view doesn't repaint on this + board — ACCEPTED as workaround-final 2026-07-13, root cause not + pursued further.** User's call: the Alt+turn workaround (shipped + 2026-07-07, commit `df7f9bc`) is the practical fix for this hardware — + a genuine root-cause attempt already made things measurably worse and + was reverted (see below), so further root-causing is not planned unless + new evidence surfaces. `navFocusCb`'s `lv_obj_scroll_to_view(f, LV_ANIM_OFF)` (the + call every board's keyboard/trackball nav relies on to bring a newly- + focused off-screen field into view) updates LVGL's internal scroll + model correctly on the pager — confirmed via hardware serial logging + that focus reaches fields far down a tall settings page (their + blur-to-save toasts fire in order) and `lv_obj_get_scroll_y()` genuinely + changes — but the glass never repaints to show the new scroll position. + A small new overlay (the toast itself) still paints fine, since that's a + fresh object, not a repaint of already-rendered scrolled content. This + never surfaced on the T-Deck/Heltec V4 because touch-drag scrolling + there never exercises this code path at all (gesture scrolling + re-invalidates every animation tick, which happens to paper over the + same underlying gap a single one-shot `LV_ANIM_OFF` call doesn't cover) + — so it's a latent bug in shared nav code, not something pager-specific + in origin, just the first board with no touch fallback to mask it. + **Attempted fix that made it WORSE, reverted**: forcing an extra + `lv_obj_invalidate()` on the scrolled ancestor right after + `scroll_to_view` — this made individual focus highlights eventually + repaint, but very slowly (~5 encoder presses per visible update), and + still never actually scrolled the viewport. Do not re-attempt this exact + fix without new evidence. **Workaround shipped instead** (not a fix): + holding the pager's orange Alt key + turning the encoder now + free-scrolls the whole page via `navScrollFocused()`'s **animated** + `lv_obj_scroll_by` (`LV_ANIM_ON`), which empirically DOES repaint + correctly — consistent with the animation-tick theory above — then + snaps focus to whatever's nearest the top of the now-visible viewport + (`navRefocusFirstVisible()`, new). Plain NEXT/PREV navigation into an + off-screen field on a tall settings page (Radio & Mesh, Profile, etc.) + is still broken without using the Alt+turn workaround, and that's + accepted — the Alt+turn gesture is the intended way to reach an + off-screen field on a tall settings page going forward, not a stopgap. + Root-causing the one-shot-invalidate failure (or checking whether the + same gap exists on the touch-less Tanmatsu) is NOT planned work unless + something forces it back open. +12. **Radio & Mesh settings: blur auto-save re-ran a full flash write + live + radio SPI reconfigure on every field defocus — RESOLVED, pager-only fix + (2026-07-08).** User-reported "navigating feels laggy" on this screen + specifically, confirmed on hardware via a `uiCp("ui:nav")` diagnostic + checkpoint (added, measured zero contribution, then removed — the nav + focus-group rebuild was NOT the cause, ruling out risk 11's "maybe it's a + slow render pipeline" theory for THIS symptom). Root cause: + `saveRadioParamsCb` (`UITask.cpp`) is wired to `LV_EVENT_DEFOCUSED` on + every textarea in `buildRadioSettings()` (freq/bw/sf/cr/tx/airtime/region) + and unconditionally called `the_mesh.savePrefs()` + + `the_mesh.applyRadioFromPrefs()` on blur, even when nothing changed. On + touch boards a field only blurs on a deliberate tap-out after an edit — + rare. On the pager there's no touch fallback, so plain keyboard/encoder + "next field" navigation blurs a field on every step: arrowing through the + 6-field row re-triggered a real flash write + LR1121 reinit up to six + times with zero edits made. Measured on hardware via `[STALL] ui:lvgl` + (the callback runs synchronously inside LVGL's event dispatch, so its + blocking I/O gets attributed to the `ui:lvgl` checkpoint even though it + isn't a rendering cost): 6 escalating stalls per navigation pass, up to + 8.7s each. **Fix**: skip `setRadioParams()`/region persistence on a + silent (DEFOCUSED) blur when the parsed field values already match + `NodePrefs`, gated `#if defined(TLORA_PAGER)` so touch boards keep their + original always-save-on-blur behavior unchanged. Post-fix hardware + capture: one `[STALL] ui:lvgl 702ms` (one-time settings-modal build cost) + instead of the 6-stall escalation, user-confirmed "much better now". +13. **Unread-message LED notification (screen-off glanceable indicator) — + INVESTIGATED, DEFERRED, not implemented (2026-07-10).** User asked whether + this board could get a Tanmatsu-style "glance at the device anytime and + see there's an unread message" indicator while the screen is off. Confirmed + from multiple sources — this repo's `pins_arduino.h` ("no onboard status + LED"), the full XL9555 channel map and I²C device list in the hardware + facts above, the trail-mate reference port (its `board_facts.h` doesn't + even carry an `led_present` field), and freshly-fetched vendor sources + (LilyGoLib's `docs/hardware/lilygo-t-lora-pager.md` hardware doc and its + actual `LilyGo_LoRa_Pager.cpp`/`.h` firmware — the same file that already + caught the DISP_RST/ch6 bug, so trusted as ground truth here too) — **this + board has no onboard LED of any kind.** Tanmatsu has one: a discrete + RGB "envelope" LED on its CH32 coprocessor (`UITask.cpp:2435`, + `msgLedRefresh()`/`msgLedFlash()`) that breathes green while unread and + flashes white on arrival, independent of screen power. The pager's only + real substitute would be pulsing `KB_BACKLIGHT` (GPIO46, wired up this + session via `updatePagerKbBacklight()`), but that's the backlight for the + ENTIRE physical keyboard, not a single low-current indicator LED — a + continuous Tanmatsu-style "breathe while unread" pattern on it would be a + real, likely-not-small standby power cost for however long a message sits + unread (hours), unlike Tanmatsu's tiny dedicated LED. The bounded + alternative (a single brief flash on arrival, mirroring the T-Deck's + existing opt-in "Flash on new message", `UITask.cpp:10446`) has negligible + power cost but was explicitly rejected by the user as not solving the + actual want: a flash you have to be looking at the instant it happens + isn't a "come back later and glance at it" indicator, just a gimmick. + **Decision: do not implement either variant.** Revisit only if this board + ever gets a hardware revision with a real low-power indicator LED, or if a + software-only always-partially-on approach becomes acceptable (not + currently the case). + +## References + +- Upstream MeshCore (MIT): `meshcore-dev/MeshCore` — does **NOT** currently have + `variants/lilygo_tlora_pager/` or `CustomLR1121*` (re-verified 2026-07-06, see + Decision ②); only `CustomLR1110{,Wrapper}.h` exists in `src/helpers/radiolib/`, + same as our own core fork. Flasher precedent (unrelated to firmware source): + `flasher.meshcore.io/lilygo-t-lora-pager/` +- Local working port (pin map + LR1121 init + keymap + AW9364/BQ27220/XL9555 usage): + `~/dev/trail-mate/boards/tlora_pager/` (esp. `src/tlora_pager_board.cpp initLoRa()`, + `include/boards/tlora_pager/tlora_pager_board.h`) and + `~/dev/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h` +- Canonical pin map (ground truth, matches trail-mate exactly): [`espressif/arduino-esp32` + `variants/lilygo_tlora_pager/pins_arduino.h`](https://github.com/espressif/arduino-esp32/blob/master/variants/lilygo_tlora_pager/pins_arduino.h) +- Official hardware doc (chip list, I²C addresses, power-rail table, electrical + specs): [LilyGoLib `docs/hardware/lilygo-t-lora-pager.md`](https://github.com/Xinyuan-LilyGO/LilyGoLib/blob/master/docs/hardware/lilygo-t-lora-pager.md) — + see the caveat under "Hardware / platform facts" above about its Pins Map + table's internal inconsistencies. +- Hardware docs: LilyGo product page (T-LoRa Pager), CNX-Software 2025-08-12 writeup, + Meshtastic device page (`meshtastic.org/docs/hardware/devices/lilygo/tpager/`) +- In-repo templates: `variants/lilygo_tdeck/` (S3 + SPI radio + shared bus), + Tanmatsu keypad-nav path in `src/ui-touch/UITask.cpp`, `TANMATSU_PORT.md` (tracker + precedent) +- TFT_eSPI's ST7796 driver support (ground truth for the CGRAM_OFFSET/ + colstart/rowstart mechanism, Decision ④): `.pio/libdeps/*/TFT_eSPI/TFT_Drivers/{ST7796_Rotation.h,ST7796_Defines.h}`, + cross-checked against `ST7789_Defines.h` and `setAddrWindow()` in + `TFT_eSPI.cpp` — pulled via the pinned `bodmer/TFT_eSPI @ ^2.5.43` lib_dep. +- AW9364 backlight driver actually consumed (not hand-rolled): `lewisxhe/SensorLib`'s + `AW9364LedDriver.hpp` (MIT, header-only) — reference copy at + `~/dev/trail-mate/.pio/libdeps/*/SensorLib/src/AW9364LedDriver.hpp`. diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md new file mode 100644 index 0000000..031126a --- /dev/null +++ b/TLORA_PAGER_SHORTCUTS.md @@ -0,0 +1,174 @@ +# T-LoRa Pager: Keyboard & Rotary Encoder Guide + +The LilyGo T-LoRa Pager has **no touchscreen and no trackball** — every +screen, every setting, and every chat is driven entirely by the physical +QWERTY keyboard and the rotary encoder knob. If you're coming from the +T-Deck or Heltec V4 (touch-first), this is the one board where learning a +handful of gestures up front pays off immediately. This guide covers both +radio variants (LR1121 and SX1262) — the UI and input handling are identical +on both. + +## The two inputs + +- **Keyboard** — a physical QWERTY matrix with a Space bar, Backspace, Enter, + a Shift key, and an orange **Fn/Alt** key (bottom-left). No dedicated Esc, + arrow keys, or number row — those are covered by combos below. +- **Rotary encoder** — turn to move the on-screen focus highlight; click + (press the knob) to select. It's your only pointing device, so almost + every screen is navigable with turn + click alone. + +Everything below only fires while you're **not** actively typing into a text +field — if a field is focused and you're typing into it, letters type +normally and these combos step out of the way. + +## Rotary encoder + +| Gesture | Action | Keyboard equivalent | +|---|---|---| +| Turn | Move focus to the next/previous item on screen | **Fn (Alt) tapped alone** moves forward one step (NEXT only — no keyboard way to go backward) | +| Short click | Select / confirm the focused item | **Enter** | +| Hold ~1 s, then release | **Back**: closes a popup → closes an open chat → goes Home → Esc (whichever applies first) | **Backspace held ~1 s** | +| **Fn (Alt) + turn**, on a main tab | Jump directly between the 5 main tabs (Chats / Contacts / Home / Map / Settings) | none — encoder only | +| **Fn (Alt) + turn**, inside a settings page or chat | Scroll the page up/down | none — encoder only | +| Turn, with a dropdown open | Scroll through the dropdown's options | none — encoder only | +| Turn, with the accent picker open | Cycle through the accent variants (see below) | none — encoder only (Fn+Space to enter is shared) | +| Turn, with the @-mention list open | Cycle through matching contacts (see below) | none — encoder only | + +The knob doubles as your only way to reach the bottom tab bar — since +there's no touch to tap an icon, **Fn+turn** while on any main tab is the +fastest way to switch sections. + +## Keyboard layout + +Base layer (no modifier): + +``` +q w e r t y u i o p +a s d f g h j k l [Enter] + z x c v b n m +[Space] +``` + +Hold **Fn (Alt)** for numbers/symbols instead: + +``` +1 2 3 4 5 6 7 8 9 0 +* / + - = : ' " @ + _ $ ; ? ! , . +[Space] +``` + +### Shift and Caps Lock + +- **Hold Shift + a letter**: that letter (or letters, for as long as you + hold Shift) types uppercase — released, typing goes back to lowercase. + Real momentary Shift, just like a normal keyboard. +- **Hold Fn (Alt), then press Shift**: while you're editing a text field, + toggles **Caps Lock** on/off — stays uppercase until you repeat the chord. + Anywhere else (not editing a field), the chord does nothing. +- Shift alone, tapped with nothing else, does nothing (as expected). +- **Hold Fn (Alt), then press Backspace**: jumps straight to the **Home** + screen — works everywhere, including while you're actively editing a field + (unlike Alt+Shift above, this one isn't context-dependent). Doesn't delete + a character or trigger the plain-Backspace gestures below. + +### Special keys + +| Key | While editing a text field | Otherwise | +|---|---|---| +| **Enter** | Send / submit / newline | Select the focused item — or, if a chat message bubble is focused, opens its action menu (Ack / Mention / Copy / Info / Block) | +| **Backspace** (tap) | Delete a character | Jump straight to the newest message if you've scrolled up in an open chat | +| **Backspace** (hold ~1 s) | — | Same as the encoder's long-press: **Back** | +| **Backspace** (hold ~1 s) *while the screen is locked* | — | Unlock the screen | +| **Space** (tap) | Types a space | — | +| **Space** (double-tap, within 250 ms) | Switches between English and your configured secondary keyboard layout | — | +| **Space** (hold ~1 s) | — | Locks the screen (shows a "Locking…" progress bar; tapping any key cancels) | +| **Fn (Alt)** tapped alone (press+release, nothing else) | — | Moves focus to the next field/item — a keyboard-only substitute for a turn of the encoder | + +The **BOOT** button (top of the device) instantly wakes the screen from +idle-dim. It does *not* unlock a screen you've manually locked with the +Space-hold gesture above — for that, hold Backspace. + +## Accented characters + +Typing a letter that has accent variants (all vowels, plus `n c s y t z l r`, +both cases) pops up a small row of alternatives above the field. To pick one +without touch: + +1. **Fn (Alt) + Space** — jumps focus into the popup. +2. **Turn the encoder** — cycles through the variants. +3. **Enter** — confirms your choice and replaces the letter you just typed. +4. **Backspace** — dismisses the popup and keeps the plain letter. + +## @-mentions + +Typing `@` followed by a few letters of a contact's name pops up a matching +list in a channel/group chat. Unlike the accent picker above, this one grabs +the encoder **immediately** — no Fn+Space needed, since it only appears once +you've deliberately started typing a mention: + +1. **Turn the encoder** — cycles through the matching contacts. +2. **Enter / encoder click** — confirms the highlighted one, replacing + `@partial` with `@FullName ` in your message. +3. **Backspace** — dismisses the list without touching what you typed. + +## Sliders + +Any focused slider (Control Center brightness, a Settings slider, the Map +zoom bar) can't be dragged without touch — instead: + +- **Q** — decrease +- **E** — increase + +Each press moves it proportionally (about 20 presses end-to-end) and +persists the new value immediately, the same as releasing a drag. + +## Map screen panning + +While the **Map** tab is active: + +- **W** — pan north +- **A** — pan west +- **S** — pan south +- **D** — pan east + +Each press shifts the view a quarter-screen and reloads tiles/markers as +needed. + +## Keyboard backlight + +**Settings → Keyboard → Keyboard backlight** cycles through three modes +(tap to advance): + +- **Off** — always dark +- **On** — always lit +- **Auto** (default) — lights up on any keypress, turns off ~3 seconds after + the last one + +Unlike the T-Deck, this is a plain on/off strip under the keys, not a +dimmable brightness curve. + +## Quick reference + +| Input | Action | +|---|---| +| Turn encoder (or Fn tap = NEXT only) | Move focus | +| Click encoder (or Enter) | Select / confirm | +| Hold encoder ~1s (or hold Backspace ~1s) | Back | +| Fn + turn (main tab) | Switch tabs | +| Fn + turn (page/chat) | Scroll | +| Fn tap alone | Next field | +| Hold Shift + letter | Momentary uppercase | +| Fn + Shift (editing a field) | Toggle Caps Lock | +| Fn + Shift (not editing a field) | Nothing | +| Fn + Backspace (anywhere) | Jump to Home | +| Fn + Space | Enter accent picker | +| @ + letters (composer) | Auto-focuses the mention list — no Fn+Space needed | +| Enter | Select / send / message action menu | +| Backspace (tap) | Delete / jump to latest message | +| Backspace (hold 1s) | Back, or unlock if locked | +| Space (tap) | Space | +| Space (double-tap) | Switch keyboard language | +| Space (hold 1s) | Lock screen | +| Q / E (slider focused) | Decrease / increase | +| W / A / S / D (Map tab) | Pan map north/west/south/east | diff --git a/boards/lilygo-t-lora-pager.json b/boards/lilygo-t-lora-pager.json new file mode 100644 index 0000000..0512256 --- /dev/null +++ b/boards/lilygo-t-lora-pager.json @@ -0,0 +1,41 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "app3M_fat9M_16MB.csv", + "memory_type": "qio_qspi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1", + "-DARDUINO_USB_CDC_ON_BOOT=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [["0x303A", "0x82D4"]], + "mcu": "esp32s3", + "variant": "lilygo_tlora_pager_pins", + "variants_dir": "variants" + }, + "connectivity": ["wifi"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "LilyGo T-LoRa-Pager (16M Flash 8M QSPI PSRAM )", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.lilygo.cc", + "vendor": "LilyGo" +} diff --git a/platformio.ini b/platformio.ini index 07c6d00..4b68102 100644 --- a/platformio.ini +++ b/platformio.ini @@ -448,18 +448,438 @@ lib_deps = densaugeo/base64 @ ~1.4.0 knolleary/PubSubClient @ ^2.8.0 -; ====================================================================== -; Elecrow ThinkNode M9 — ESP32-S3R8 / LR1110 / 2.4" 240x320 ST7789 / QWERTY -; keyboard + d-pad (no touchscreen). Pin map verified against the V1.0 -; schematic + Meshtastic boot log during Specter (ESP-IDF) bring-up; the -; panel IC, TCXO voltage, battery divider, and LR1110 RF-switch DIO mapping -; below are all schematic/manufacturer-confirmed (NOT the earlier ILI9341 / -; 2:1-divider / unverified-RF-switch state from a prior attempt). See -; variants/thinknode_m9/M9_PORT.md for the full pin table and the remaining -; hardware-verify checklist (GPS EN polarity/baud, display rotation, RF-switch -; truth-table polarity, SD mount-timing margins) — those items are -; deliberately left unset/unconfirmed below. -; ====================================================================== +[env:tlora_pager_lr1121_companion_radio_touch] +platform = platformio/espressif32@6.11.0 +framework = arduino +board = lilygo-t-lora-pager +monitor_speed = 115200 +board_build.partitions = variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv +extra_scripts = pre:scripts/inject_wifi_env.py, merge-bin.py + +build_flags = + -Wno-deprecated-declarations -Wno-unused-parameter -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 + -D MC_VENDORED_TOUCH_APP + -D LORA_FREQ=869.618 + -D LORA_BW=62.5 + -D LORA_SF=8 + -D ENABLE_PRIVATE_KEY_IMPORT=1 + -D ENABLE_PRIVATE_KEY_EXPORT=1 + -D RADIOLIB_EXCLUDE_CC1101=1 + -D RADIOLIB_EXCLUDE_RF69=1 + -D RADIOLIB_EXCLUDE_SX1231=1 + -D RADIOLIB_EXCLUDE_SI443X=1 + -D RADIOLIB_EXCLUDE_RFM2X=1 + -D RADIOLIB_EXCLUDE_SX128X=1 + -D RADIOLIB_EXCLUDE_AFSK=1 + -D RADIOLIB_EXCLUDE_AX25=1 + -D RADIOLIB_EXCLUDE_HELLSCHREIBER=1 + -D RADIOLIB_EXCLUDE_MORSE=1 + -D RADIOLIB_EXCLUDE_APRS=1 + -D RADIOLIB_EXCLUDE_BELL=1 + -D RADIOLIB_EXCLUDE_RTTY=1 + -D RADIOLIB_EXCLUDE_SSTV=1 + ; LR11X0 stays IN (that's our radio family); SX126X isn't used by this env at all. + -D RADIOLIB_EXCLUDE_SX126X=1 + -D ESP32_PLATFORM + ; --- T-LoRa Pager board --- + -I variants/lilygo_tlora_pager + -D TLORA_PAGER=1 + -D BOARD_HAS_PSRAM=1 + ; 0 = silence ARDUHAL [E] spam, same rationale as the other two envs. + -D CORE_DEBUG_LEVEL=0 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D PIN_USER_BTN=0 + -D USE_LR1121=1 + -D RADIO_CLASS=CustomLR1121 + -D WRAPPER_CLASS=CustomLR1121Wrapper + ; Full sub-GHz HP-PA power on boot, matching the T-Deck's own LORA_TX_POWER=22 + ; (not Heltec V4's conservative-10-then-raise-in-Settings pattern). +22dBm is + ; this chip's real ceiling for freq < 1GHz -- confirmed against RadioLib's + ; pinned LR1120::checkOutputPower() (LR1121 inherits it: HP PA auto-selects + ; above 14dBm, valid range -9..22) and trail-mate's own + ; TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 for this exact board -- not a guess + ; carried over from the sibling boards. A weak default here (10dBm, ~16x less + ; power) was silently degrading outbound range on real hardware: adverts/DMs + ; sent FROM the pager were unreliable while inbound reception was fine, since + ; the far node's own TX was unaffected. + -D LORA_TX_POWER=22 + -D P_LORA_DIO_1=14 + -D P_LORA_NSS=36 + -D P_LORA_RESET=47 + -D P_LORA_BUSY=48 + -D P_LORA_SCLK=35 + -D P_LORA_MISO=33 + -D P_LORA_MOSI=34 + -D ENV_INCLUDE_GPS=1 + -D ENV_SKIP_GPS_DETECT=1 + -D ENV_INCLUDE_AHTX0=0 + -D ENV_INCLUDE_BME280=0 + -D ENV_INCLUDE_BMP280=0 + -D ENV_INCLUDE_SHTC3=0 + -D ENV_INCLUDE_SHT4X=0 + -D ENV_INCLUDE_LPS22HB=0 + -D ENV_INCLUDE_INA3221=0 + -D ENV_INCLUDE_INA219=0 + -D ENV_INCLUDE_INA226=0 + -D ENV_INCLUDE_INA260=0 + -D ENV_INCLUDE_MLX90614=0 + -D ENV_INCLUDE_VL53L0X=0 + -D ENV_INCLUDE_BME680=0 + -D ENV_INCLUDE_BMP085=0 + -D DISPLAY_SCALE_X=1.0 + -D DISPLAY_SCALE_Y=1.0 + ; AW9364 backlight enable/pulse pin (ST7796LCDDisplay owns brightness -- see + ; the CGRAM_OFFSET comment below for why this isn't a plain PWM pin). + -D PIN_TFT_LEDA_CTL=42 + ; Swapped vs. trail-mate's raw GPS_RX=4/GPS_TX=12: wadamesh's core calls + ; Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX), and HardwareSerial::setPins() + ; takes (rxPin, txPin) -- so PIN_GPS_TX must be the ESP's own RX pin (4) and + ; PIN_GPS_RX the ESP's own TX pin (12). See TLORA_PAGER_PORT.md Risk 1c. + -D PIN_GPS_RX=12 + -D PIN_GPS_TX=4 + -D GPS_BAUD_RATE=38400 + ; --- touch UI / companion stack --- + -I include + -I src + -I src/ui-touch + -D UI_LVGL=1 + -D HAS_TOUCH_UI=1 + -D FIRMWARE_OTA_ENV='"tlora_pager_lr1121_companion_radio_touch"' + -D HAS_PAGER_KEYBOARD=1 + -D HAS_PAGER_ENCODER=1 + -D KB_INT=6 + -D KB_BACKLIGHT=46 + -D ROTARY_A=40 + -D ROTARY_B=41 + -D ROTARY_C=7 + ; ES8311 codec I2S pins (NS4150B amp downstream, enabled via XL9555 + ; AMP_EN -- see TLoraPagerBoard::setAmpEnabled()). Confirmed against the + ; canonical arduino-esp32 pins_arduino.h for this board -- see + ; TLORA_PAGER_PORT.md's Audio row. + -D PIN_I2S_MCLK=10 + -D PIN_I2S_BCK=11 + -D PIN_I2S_WS=18 + -D PIN_I2S_DOUT=45 + -D PIN_I2S_SDIN=17 + -D ENABLE_ADVERT_ON_BOOT=0 + -D LV_CONF_PATH=lv_conf.h + -D LV_CONF_INCLUDE_SIMPLE=1 + ; Smoother scroll: lower deceleration than LVGL's default (10) so flicks glide + ; longer instead of stopping abruptly. lv_conf leaves this unset, so this -D wins. + -D LV_INDEV_DEF_SCROLL_THROW=7 + -D MAX_CONTACTS=2000 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=ST7796LCDDisplay + -D ADMIN_PASSWORD='"password"' + -D MULTI_TRANSPORT_COMPANION=1 + -D WADAMESH_FORK_BUILD=1 + -D TCP_PORT=5000 + -D WS_PORT=8765 + -D BLE_PIN_CODE=123456 + -D CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=1 + -D CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384 + -D CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096 + ; --- ST7796 display (TFT_eSPI) --- + -D USER_SETUP_LOADED=1 + -D ST7796_DRIVER=1 + ; REQUIRED: this panel's 222px glass is narrower than the ST7796 controller's + ; 320px GRAM. TFT_eSPI's ST7796_Rotation.h only applies the needed 49px + ; column/row offset when this is defined (unlike ST7789, it isn't automatic + ; for ST7796) -- ST7796LCDDisplay.cpp #errors at compile time without it. + -D CGRAM_OFFSET=1 + ; REQUIRED on ESP32-S3: TFT_eSPI_ESP32_S3.h's raw register macros (_spi_user, + ; _spi_cmd, _spi_mosi_dlen -- used by begin_tft_write()/TFT_WRITE_BITS) take + ; SPI_PORT and feed it straight into IDF's SPI_USER_REG(i)/REG_SPI_BASE(i), + ; which only resolves to a real peripheral for i>=2 (SPI2/SPI3); i<2 returns + ; base address 0. Leaving both USE_HSPI_PORT/USE_FSPI_PORT undefined makes + ; that file fall back to SPI_PORT=FSPI, but FSPI is the *Arduino* enum value + ; (0 on S3) rather than the raw IDF host index TFT_eSPI's own macros need -- + ; so _spi_user resolves to address 0x10, and the very first writecommand() + ; in ST7796LCDDisplay::begin()/TFT_eSPI::init() hard-crashes with a + ; StoreProhibited panic (confirmed on hardware: black screen, boot-looping + ; "Guru Meditation Error" at TFT_eSPI::begin_tft_write()). USE_FSPI_PORT + ; forces SPI_PORT=2 (real SPI2/"GPSPI2") consistently across both the raw + ; macros and the SPIClass(FSPI) object TFT_eSPI constructs internally. + ; USE_HSPI_PORT (real SPI3) is deliberately NOT used here instead: the + ; radio's own `SPIClass spi` (variants/lilygo_tlora_pager/target.cpp) takes + ; its default ctor arg (HSPI), which the Arduino core also maps to real + ; SPI3 -- so picking HSPI for the display would put both peripherals on the + ; same physical SPI host and conflict. + -D USE_FSPI_PORT=1 + -D TFT_WIDTH=222 + -D TFT_HEIGHT=480 + -D TFT_MOSI=34 + -D TFT_SCLK=35 + -D TFT_CS=38 + -D TFT_DC=37 + -D TFT_RST=-1 + -D TFT_MISO=33 + -D TFT_BL=-1 + ; trail-mate's kPagerDisplaySpiClockMhz = 80. + -D SPI_FREQUENCY=80000000 + +build_src_filter = + +<*.cpp> + +<*.c> + + + + + + + + + + + + + +<../variants/lilygo_tlora_pager/*.cpp> + +lib_deps = + https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.5 + SPI + Wire + jgromes/RadioLib @ ^7.6.0 + rweather/Crypto @ ^0.4.0 + adafruit/RTClib @ ^2.1.3 + melopero/Melopero RV3028 @ ^1.1.0 + electroniccats/CayenneLPP @ 1.6.1 + h2zero/NimBLE-Arduino @ 1.4.3 + adafruit/Adafruit INA3221 Library @ ^1.0.1 + adafruit/Adafruit INA219 @ ^1.2.3 + robtillaart/INA226 @ ^0.6.4 + adafruit/Adafruit INA260 Library @ ^1.5.3 + adafruit/Adafruit AHTX0 @ ^2.0.5 + adafruit/Adafruit BME280 Library @ ^2.3.0 + adafruit/Adafruit BMP280 Library @ ^2.6.8 + adafruit/Adafruit SHTC3 Library @ ^1.0.1 + sensirion/Sensirion I2C SHT4x @ ^1.1.2 + arduino-libraries/Arduino_LPS22HB @ ^1.0.2 + adafruit/Adafruit MLX90614 Library @ ^2.1.5 + adafruit/Adafruit_VL53L0X @ ^1.2.4 + stevemarple/MicroNMEA @ ^2.0.6 + adafruit/Adafruit BME680 Library @ ^2.0.4 + adafruit/Adafruit BMP085 Library @ ^1.2.4 + ESP32Async/ESPAsyncWebServer @ 3.10.3 + file://arch/esp32/AsyncElegantOTA + lvgl/lvgl @ ^8.3.11 + densaugeo/base64 @ ~1.4.0 + knolleary/PubSubClient @ ^2.8.0 + bodmer/TFT_eSPI @ ^2.5.43 + adafruit/Adafruit TCA8418 @ ^1.0.2 + lewisxhe/SensorLib @ 0.3.3 + +; Milestone 10: SX1262-variant env for pager units sold with that radio instead +; of LR1121 -- same board JSON, same variant dir, same physical NSS/RESET/BUSY/ +; DIO1/SCLK/MISO/MOSI pins (same PCB slot, just a different chip populated — +; see TLORA_PAGER_PORT.md's hardware-facts table). RADIO_CLASS/WRAPPER_CLASS +; swap to the core fork's CustomSX1262/CustomSX1262Wrapper, the exact same +; generic classes the T-Deck already uses -- see target.cpp's radio_init() +; for why no new radio C++ code was needed, just these build flags. +[env:tlora_pager_sx1262_companion_radio_touch] +platform = platformio/espressif32@6.11.0 +framework = arduino +board = lilygo-t-lora-pager +monitor_speed = 115200 +board_build.partitions = variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv +extra_scripts = pre:scripts/inject_wifi_env.py, merge-bin.py + +build_flags = + -Wno-deprecated-declarations -Wno-unused-parameter -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 + -D MC_VENDORED_TOUCH_APP + -D LORA_FREQ=869.618 + -D LORA_BW=62.5 + -D LORA_SF=8 + -D ENABLE_PRIVATE_KEY_IMPORT=1 + -D ENABLE_PRIVATE_KEY_EXPORT=1 + -D RADIOLIB_EXCLUDE_CC1101=1 + -D RADIOLIB_EXCLUDE_RF69=1 + -D RADIOLIB_EXCLUDE_SX1231=1 + -D RADIOLIB_EXCLUDE_SI443X=1 + -D RADIOLIB_EXCLUDE_RFM2X=1 + -D RADIOLIB_EXCLUDE_SX128X=1 + -D RADIOLIB_EXCLUDE_AFSK=1 + -D RADIOLIB_EXCLUDE_AX25=1 + -D RADIOLIB_EXCLUDE_HELLSCHREIBER=1 + -D RADIOLIB_EXCLUDE_MORSE=1 + -D RADIOLIB_EXCLUDE_APRS=1 + -D RADIOLIB_EXCLUDE_BELL=1 + -D RADIOLIB_EXCLUDE_RTTY=1 + -D RADIOLIB_EXCLUDE_SSTV=1 + ; SX126X stays IN (that's this env's radio family); LR11X0 isn't used at all. + -D RADIOLIB_EXCLUDE_LR11X0=1 + -D ESP32_PLATFORM + ; --- T-LoRa Pager board (SX1262 variant) --- + -I variants/lilygo_tlora_pager + -D TLORA_PAGER=1 + -D BOARD_HAS_PSRAM=1 + ; 0 = silence ARDUHAL [E] spam, same rationale as the other envs. + -D CORE_DEBUG_LEVEL=0 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D PIN_USER_BTN=0 + -D USE_SX1262=1 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + ; Same 22dBm ceiling as the LR1121 env -- trail-mate's own reference for this + ; exact board (~/dev/trail-mate/boards/tlora_pager) uses the identical + ; TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22 for BOTH its LR1121 and SX1262 envs, and + ; it's this chip's well-known real ceiling too. + -D LORA_TX_POWER=22 + ; SX126X_DIO2_AS_RF_SWITCH=true and SX126X_CURRENT_LIMIT=140 are carried over + ; from the T-Deck's own SX1262 config -- NOT because this is the T-Deck, but + ; because both are tied to the 22dBm target above, not to board-specific analog + ; circuitry: RadioLib's own SX126x.cpp already defaults DIO2-as-switch to true + ; internally (same value, just made explicit here), and its default current + ; limit (60mA) is what silently capped the T-Deck's real TX power below its + ; target before that env raised it to 140mA -- the same physics apply to any + ; SX1262 module asked to reach 22dBm, not something unique to the T-Deck's PCB. + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + ; Deliberately NOT setting SX126X_DIO3_TCXO_VOLTAGE (unlike the T-Deck's + ; 1.8f) -- that IS a board-specific analog value (this board's actual TCXO + ; circuit), and trail-mate's own working reference for this exact PCB never + ; overrides it either, relying on CustomSX1262::std_init()'s built-in 1.6V + ; fallback (RadioLib's own SX1262::begin() default) -- which also already + ; auto-retries at 0.0f on an SPI-cmd failure if 1.6V doesn't suit this board's + ; real hardware. Revisit with a measured value if bring-up shows otherwise. + -D P_LORA_DIO_1=14 + -D P_LORA_NSS=36 + -D P_LORA_RESET=47 + -D P_LORA_BUSY=48 + -D P_LORA_SCLK=35 + -D P_LORA_MISO=33 + -D P_LORA_MOSI=34 + -D ENV_INCLUDE_GPS=1 + -D ENV_SKIP_GPS_DETECT=1 + -D ENV_INCLUDE_AHTX0=0 + -D ENV_INCLUDE_BME280=0 + -D ENV_INCLUDE_BMP280=0 + -D ENV_INCLUDE_SHTC3=0 + -D ENV_INCLUDE_SHT4X=0 + -D ENV_INCLUDE_LPS22HB=0 + -D ENV_INCLUDE_INA3221=0 + -D ENV_INCLUDE_INA219=0 + -D ENV_INCLUDE_INA226=0 + -D ENV_INCLUDE_INA260=0 + -D ENV_INCLUDE_MLX90614=0 + -D ENV_INCLUDE_VL53L0X=0 + -D ENV_INCLUDE_BME680=0 + -D ENV_INCLUDE_BMP085=0 + -D DISPLAY_SCALE_X=1.0 + -D DISPLAY_SCALE_Y=1.0 + ; AW9364 backlight enable/pulse pin (ST7796LCDDisplay owns brightness -- see + ; the CGRAM_OFFSET comment below for why this isn't a plain PWM pin). + -D PIN_TFT_LEDA_CTL=42 + ; Swapped vs. trail-mate's raw GPS_RX=4/GPS_TX=12: wadamesh's core calls + ; Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX), and HardwareSerial::setPins() + ; takes (rxPin, txPin) -- so PIN_GPS_TX must be the ESP's own RX pin (4) and + ; PIN_GPS_RX the ESP's own TX pin (12). See TLORA_PAGER_PORT.md Risk 1c. + -D PIN_GPS_RX=12 + -D PIN_GPS_TX=4 + -D GPS_BAUD_RATE=38400 + ; --- touch UI / companion stack --- + -I include + -I src + -I src/ui-touch + -D UI_LVGL=1 + -D HAS_TOUCH_UI=1 + -D FIRMWARE_OTA_ENV='"tlora_pager_sx1262_companion_radio_touch"' + -D HAS_PAGER_KEYBOARD=1 + -D HAS_PAGER_ENCODER=1 + -D KB_INT=6 + -D KB_BACKLIGHT=46 + -D ROTARY_A=40 + -D ROTARY_B=41 + -D ROTARY_C=7 + ; ES8311 codec I2S pins (NS4150B amp downstream, enabled via XL9555 + ; AMP_EN -- see TLoraPagerBoard::setAmpEnabled()). Confirmed against the + ; canonical arduino-esp32 pins_arduino.h for this board -- see + ; TLORA_PAGER_PORT.md's Audio row. + -D PIN_I2S_MCLK=10 + -D PIN_I2S_BCK=11 + -D PIN_I2S_WS=18 + -D PIN_I2S_DOUT=45 + -D PIN_I2S_SDIN=17 + -D ENABLE_ADVERT_ON_BOOT=0 + -D LV_CONF_PATH=lv_conf.h + -D LV_CONF_INCLUDE_SIMPLE=1 + ; Smoother scroll: lower deceleration than LVGL's default (10) so flicks glide + ; longer instead of stopping abruptly. lv_conf leaves this unset, so this -D wins. + -D LV_INDEV_DEF_SCROLL_THROW=7 + -D MAX_CONTACTS=2000 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=ST7796LCDDisplay + -D ADMIN_PASSWORD='"password"' + -D MULTI_TRANSPORT_COMPANION=1 + -D WADAMESH_FORK_BUILD=1 + -D TCP_PORT=5000 + -D WS_PORT=8765 + -D BLE_PIN_CODE=123456 + -D CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=1 + -D CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384 + -D CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096 + ; --- ST7796 display (TFT_eSPI) --- + -D USER_SETUP_LOADED=1 + -D ST7796_DRIVER=1 + ; REQUIRED: this panel's 222px glass is narrower than the ST7796 controller's + ; 320px GRAM. TFT_eSPI's ST7796_Rotation.h only applies the needed 49px + ; column/row offset when this is defined (unlike ST7789, it isn't automatic + ; for ST7796) -- ST7796LCDDisplay.cpp #errors at compile time without it. + -D CGRAM_OFFSET=1 + ; REQUIRED on ESP32-S3: see the LR1121 env's identical comment above -- + ; same display, same panel, same constraint, unrelated to the radio chip. + -D USE_FSPI_PORT=1 + -D TFT_WIDTH=222 + -D TFT_HEIGHT=480 + -D TFT_MOSI=34 + -D TFT_SCLK=35 + -D TFT_CS=38 + -D TFT_DC=37 + -D TFT_RST=-1 + -D TFT_MISO=33 + -D TFT_BL=-1 + ; trail-mate's kPagerDisplaySpiClockMhz = 80. + -D SPI_FREQUENCY=80000000 + +build_src_filter = + +<*.cpp> + +<*.c> + + + + + + + + + + + + + +<../variants/lilygo_tlora_pager/*.cpp> + +lib_deps = + https://github.com/ALLFATHER-BV/meshcomod.git#core-v1.16.5 + SPI + Wire + jgromes/RadioLib @ ^7.6.0 + rweather/Crypto @ ^0.4.0 + adafruit/RTClib @ ^2.1.3 + melopero/Melopero RV3028 @ ^1.1.0 + electroniccats/CayenneLPP @ 1.6.1 + h2zero/NimBLE-Arduino @ 1.4.3 + adafruit/Adafruit INA3221 Library @ ^1.0.1 + adafruit/Adafruit INA219 @ ^1.2.3 + robtillaart/INA226 @ ^0.6.4 + adafruit/Adafruit INA260 Library @ ^1.5.3 + adafruit/Adafruit AHTX0 @ ^2.0.5 + adafruit/Adafruit BME280 Library @ ^2.3.0 + adafruit/Adafruit BMP280 Library @ ^2.6.8 + adafruit/Adafruit SHTC3 Library @ ^1.0.1 + sensirion/Sensirion I2C SHT4x @ ^1.1.2 + arduino-libraries/Arduino_LPS22HB @ ^1.0.2 + adafruit/Adafruit MLX90614 Library @ ^2.1.5 + adafruit/Adafruit_VL53L0X @ ^1.2.4 + stevemarple/MicroNMEA @ ^2.0.6 + adafruit/Adafruit BME680 Library @ ^2.0.4 + adafruit/Adafruit BMP085 Library @ ^1.2.4 + ESP32Async/ESPAsyncWebServer @ 3.10.3 + file://arch/esp32/AsyncElegantOTA + lvgl/lvgl @ ^8.3.11 + densaugeo/base64 @ ~1.4.0 + knolleary/PubSubClient @ ^2.8.0 + bodmer/TFT_eSPI @ ^2.5.43 + adafruit/Adafruit TCA8418 @ ^1.0.2 + lewisxhe/SensorLib @ 0.3.3 [env:ThinkNode_M9_companion_radio_touch] platform = platformio/espressif32@6.11.0 framework = arduino diff --git a/src/DataStore.cpp b/src/DataStore.cpp index cfe244c..77e4087 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -277,7 +277,7 @@ void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) namespace { void default_rx_boosted_gain_pref(uint8_t& out) { -#if defined(USE_SX1262) || defined(USE_SX1268) || defined(SX126X_RX_BOOSTED_GAIN) +#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1121) || defined(SX126X_RX_BOOSTED_GAIN) #ifdef SX126X_RX_BOOSTED_GAIN out = (SX126X_RX_BOOSTED_GAIN != 0) ? 1 : 0; #else diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp index e0fd1ee..718f770 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -2541,7 +2541,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default //_prefs.rx_delay_base = 10.0f; enable once new algo fixed -#if defined(USE_SX1262) || defined(USE_SX1268) +#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1121) #ifdef SX126X_RX_BOOSTED_GAIN _prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN ? 1 : 0; #else @@ -2557,7 +2557,7 @@ void MyMesh::applyRadioFromPrefs() { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.radioRelease(); radio_driver.setTxPower(_prefs.tx_power_dbm); -#if defined(USE_SX1262) || defined(USE_SX1268) +#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1121) _prefs.rx_boosted_gain = _prefs.rx_boosted_gain ? 1 : 0; radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain != 0); MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", @@ -3356,7 +3356,7 @@ void MyMesh::handleCmdFrame(size_t len) { radio_driver.radioAcquire(); // hold off the RX drain task mid-sequence (no-op when off) radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); -#if defined(USE_SX1262) || defined(USE_SX1268) || defined(SX126X_RX_BOOSTED_GAIN) +#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1121) || defined(SX126X_RX_BOOSTED_GAIN) radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain != 0); #endif radio_driver.radioRelease(); diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 2af8289..64d0add 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -1811,7 +1811,14 @@ bool touchPrefsSetBlob(const char* key, const uint8_t* data, size_t len) { } uint8_t touchPrefsGetSoundVolume() { if (!s_begun) touchPrefsBegin(); +#if defined(TLORA_PAGER) + // The pager's ES8311 codec + NS4150B amp run noticeably louder at a given + // percentage than the T-Deck's I2S amp/Tanmatsu's ES8156 — 70% clips into + // uncomfortable territory, so this board gets a quieter first-boot default. + uint8_t v = s_prefs.getUChar("snd_vol", 50); +#else uint8_t v = s_prefs.getUChar("snd_vol", 70); +#endif return v > 100 ? 100 : v; } void touchPrefsSetSoundVolume(uint8_t vol) { if (vol > 100) vol = 100; if (!s_begun) touchPrefsBegin(); prefsPutUChar("snd_vol", vol); } diff --git a/src/helpers/input/PagerEncoder.cpp b/src/helpers/input/PagerEncoder.cpp new file mode 100644 index 0000000..801727f --- /dev/null +++ b/src/helpers/input/PagerEncoder.cpp @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "PagerEncoder.h" + +#if defined(HAS_PAGER_ENCODER) && defined(ESP32) + +#include + +#ifndef ROTARY_A + #define ROTARY_A 40 +#endif +#ifndef ROTARY_B + #define ROTARY_B 41 +#endif +#ifndef ROTARY_C + #define ROTARY_C 7 +#endif + +// Mechanical detent ratio: most EC11-style encoders emit 4 quadrature +// transitions per detent (rest state -> rest state). NOT independently +// verified against this exact part — confirm on hardware (Milestone 7) and +// adjust if this encoder differs. +#ifndef PAGER_ENCODER_STEPS_PER_DETENT + #define PAGER_ENCODER_STEPS_PER_DETENT 4 +#endif + +// Standard Gray-code quadrature transition table: index = (prev_AB << 2 | +// curr_AB), value = +1 (CW), -1 (CCW), or 0 (bounce/invalid transition). +static const int8_t kQuadTable[16] = { + 0, -1, 1, 0, + 1, 0, 0, -1, + -1, 0, 0, 1, + 0, 1, -1, 0, +}; + +static volatile int32_t s_raw_delta = 0; +static volatile uint8_t s_ab_state = 0; +static bool s_inited = false; + +static void IRAM_ATTR pagerEncoderISR() { + const uint8_t a = (uint8_t)digitalRead(ROTARY_A); + const uint8_t b = (uint8_t)digitalRead(ROTARY_B); + const uint8_t curr = (uint8_t)((a << 1) | b); + const uint8_t idx = (uint8_t)((s_ab_state << 2) | curr); + s_raw_delta += kQuadTable[idx]; + s_ab_state = curr; +} + +void pagerEncoderBegin() { + if (s_inited) return; + s_inited = true; + pinMode(ROTARY_A, INPUT_PULLUP); + pinMode(ROTARY_B, INPUT_PULLUP); + pinMode(ROTARY_C, INPUT_PULLUP); + s_ab_state = (uint8_t)((digitalRead(ROTARY_A) << 1) | digitalRead(ROTARY_B)); + attachInterrupt(digitalPinToInterrupt(ROTARY_A), pagerEncoderISR, CHANGE); + attachInterrupt(digitalPinToInterrupt(ROTARY_B), pagerEncoderISR, CHANGE); +} + +int pagerEncoderReadDelta() { + noInterrupts(); + int32_t raw = s_raw_delta; + s_raw_delta = raw % PAGER_ENCODER_STEPS_PER_DETENT; // keep the partial-detent remainder + interrupts(); + // Confirmed inverted on hardware (2026-07-07): kQuadTable's CW/CCW sign convention + // doesn't match this physical part's wiring -- turning the knob the way a user expects + // to move forward/down moved focus and page-scroll backward/up instead. Negate here + // (not the table) so every consumer (nav NEXT/PREV, Alt+turn tab-switch, Alt+turn page + // scroll) inherits the corrected sense from one place. + return -(int)(raw / PAGER_ENCODER_STEPS_PER_DETENT); +} + +bool pagerEncoderClickHeld() { + return digitalRead(ROTARY_C) == LOW; // active-low, matches TDeckTrackball's click idiom +} + +#endif diff --git a/src/helpers/input/PagerEncoder.h b/src/helpers/input/PagerEncoder.h new file mode 100644 index 0000000..6dcdf79 --- /dev/null +++ b/src/helpers/input/PagerEncoder.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// T-LoRa Pager rotary encoder: a true quadrature A/B pair (GPIO 40/41) with +// the center press on GPIO 7. Decoded via ISR edge-counting in the same +// spirit as TDeckTrackball.cpp's direction pins, but this is a genuine +// quadrature pair rather than 4 independent direction pulses, so the ISR does +// a standard Gray-code state-transition lookup (both edges of both A and B) +// instead of a plain per-pin counter, and pagerEncoderReadDelta() returns +// signed detents rather than a 2D motion vector. +#if defined(HAS_PAGER_ENCODER) && defined(ESP32) + +#include + +/** Configure the A/B quadrature pins + press pin and attach the ISRs. + * One-shot. */ +void pagerEncoderBegin(); + +/** Signed detents accumulated since the last call (0 if none). Positive = + * clockwise. Safe to call from any single consistent context. */ +int pagerEncoderReadDelta(); + +/** True while the encoder's center button is held (active-low GPIO 7). */ +bool pagerEncoderClickHeld(); + +#endif diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp new file mode 100644 index 0000000..e357368 --- /dev/null +++ b/src/helpers/input/PagerKeyboard.cpp @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "PagerKeyboard.h" + +#if defined(HAS_PAGER_KEYBOARD) && defined(ESP32) + +#include +#include +#include +#include + +#ifndef KB_INT + #define KB_INT 6 +#endif +#ifndef KB_BACKLIGHT + #define KB_BACKLIGHT 46 +#endif +#define KB_ROWS 4 +#define KB_COLS 10 + +// Matrix legend, s_keymap[row][col] — same physical keyboard PCB as +// trail-mate's working LR1121 pager build, cross-checked there. '\0' = no +// character at that position (dead cell, or intercepted as a modifier below). +static constexpr char s_keymap[KB_ROWS][KB_COLS] = { + {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'}, + {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\r'}, + {'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '\0', '\0'}, + {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, +}; +// Alt layer (symbols/numbers) — this hardware has no separate physical Symbol +// key, so Alt alone drives it (matches trail-mate's has_symbol_key=false path). +static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { + {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}, + {'*', '/', '+', '-', '=', ':', '\'', '"', '@', '\0'}, + {'\0', '_', '$', ';', '?', '!', ',', '.', '\0', '\0'}, + {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, +}; + +// Modifier/special-key positions: 0-based (row*KB_COLS + col), matching the +// TCA8418 raw event's (code & 0x7F) - 1. Alt is a hold (symbol layer while +// held); Shift is a hold too (momentary uppercase on the base layer, real +// Shift-key semantics) — held Alt THEN a Shift press instead chords into +// Alt+Shift, reported via s_alt_shift_chord_pending. What that chord DOES is +// a UI-level decision (UITask.cpp): Caps Lock toggle while editing a text +// field, no-op otherwise — this driver has no idea which field (if any) is +// focused, so it only reports the chord, it doesn't act on it (see +// pagerKeyboardConsumeAltShiftChord()/pagerKeyboardToggleCaps()). Held Alt +// THEN a Backspace press similarly chords into Alt+Backspace +// (s_alt_backspace_chord_pending) — unlike Alt+Shift this one has a single, +// context-independent effect (jump Home, everywhere, including mid-edit), +// so it's just as driver-agnostic to report but never conditional at the UI +// layer. Note: Alt (row2,col0) and Shift (row2,col8) share row2, and +// row0/row1 col8 are 'o'/'l' — holding Alt+Shift+O or Alt+Shift+L all three +// at once will phantom-ghost a 'q'/'a' at the row2/col0 intersection +// (classic diode-less-matrix 3-key rectangle, no software fix possible); +// harmless in practice since the intended gesture is +// hold-Alt-tap-Shift-release-both, not holding all three simultaneously. +// Backspace (row2,col9) sits one column over from Shift, so Alt+Backspace +// doesn't share this exact ghosting risk with any base-layer letter. +static constexpr uint8_t kAltPos = 2 * KB_COLS + 0; // row2,col0 ('\0' in both layers) +static constexpr uint8_t kShiftPos = 2 * KB_COLS + 8; // row2,col8 ('\0' in both layers) +static constexpr uint8_t kBackspacePos = 2 * KB_COLS + 9; // row2,col9 ('\0' in both layers) +static constexpr uint8_t kSpacePos = 3 * KB_COLS + 0; // row3,col0 (' ' in both layers) + +static Adafruit_TCA8418 s_kb; +static bool s_inited = false; +static bool s_alt = false; +static bool s_alt_used = false; // Alt consumed as a modifier since it was last pressed +static bool s_alt_tap_pending = false; // Alt pressed+released with nothing else happening meanwhile +static bool s_caps = false; +static bool s_shift_held = false; // momentary Shift, mirrors s_backspace_held/s_space_held +static bool s_alt_shift_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltShiftChord() +static bool s_alt_backspace_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltBackspaceChord() +static bool s_backspace_held = false; +static bool s_space_held = false; + +// Single-producer (poll) / single-consumer (UI thread) ring — same pattern as +// TDeckKeyboard.cpp; byte indices are atomic enough for SPSC without a lock. +static volatile uint8_t s_ring[16]; +static volatile uint8_t s_head = 0; +static volatile uint8_t s_tail = 0; + +static bool s_bl_ready = false; +// This framework's Arduino-ESP32 core only has the channel-based LEDC API +// (ledcSetup/ledcAttachPin/ledcWrite by channel — confirmed against +// esp32-hal-ledc.h, not the newer pin-based ledcAttach()). Channel 0: nothing +// else on this board claims an LEDC channel (the AW9364 display backlight is +// pulse-driven, not PWM). +static constexpr uint8_t kKbBacklightPwmChannel = 0; + +static void ringPush(uint8_t c) { + const uint8_t nh = (uint8_t)((s_head + 1) & 15); + if (nh != s_tail) { // drop if the ring is full + s_ring[s_head] = c; + s_head = nh; + } +} + +void pagerKeyboardBegin() { + if (s_inited) return; + s_inited = s_kb.begin(TCA8418_DEFAULT_ADDR, &Wire) && s_kb.matrix(KB_ROWS, KB_COLS); + if (!s_inited) return; + s_kb.flush(); + pinMode(KB_INT, INPUT_PULLUP); // TCA8418 INT is open-drain active-low; not ISR-driven here (see .h) + s_kb.enableInterrupts(); +} + +void pagerKeyboardPoll() { + if (!s_inited) return; + while (s_kb.available()) { + const uint8_t raw = s_kb.getEvent(); + if (raw == 0) break; + // TCA8418 KEY_EVENT_A bit 7: 1 = press, 0 = release (TI datasheet SCPS215E + // register description, verified directly — the Adafruit library's own + // header comment states this backwards; don't trust it). + const bool pressed = (raw & 0x80) != 0; + const uint8_t code = (uint8_t)((raw & 0x7F) - 1); + + if (code == kAltPos) { + // Solo tap (press+release, nothing else in between) vs. a modifier hold + // (symbol-layer typing, or the rotary encoder's Alt+turn via + // pagerKeyboardMarkAltUsed()) — only the former queues a pending tap. + if (pressed) { s_alt = true; s_alt_used = false; } + else { if (!s_alt_used) s_alt_tap_pending = true; s_alt = false; } + continue; + } + // Any other key event while Alt is held means Alt is being used as a + // modifier, not tapped solo — cancels the pending-tap interpretation. + if (s_alt && pressed) s_alt_used = true; + if (code == kShiftPos) { + if (pressed) { + if (s_alt) s_alt_shift_chord_pending = true; // Alt (Fn) held + Shift press = chord (UI decides the effect) + else s_shift_held = true; + } else { + s_shift_held = false; + } + continue; + } + if (code == kBackspacePos) { + if (pressed) { + // Alt+Backspace is a distinct chord (jump Home, everywhere -- see + // pagerKeyboardConsumeAltBackspaceChord()), not a delete: suppress + // both the '\b' ring-push AND s_backspace_held, so the plain- + // Backspace hold gestures (back / unlock) never also see this press. + if (s_alt) s_alt_backspace_chord_pending = true; + else { s_backspace_held = true; ringPush('\b'); } + } else { + s_backspace_held = false; + } + continue; + } + if (code == kSpacePos) { s_space_held = pressed; if (pressed) ringPush(' '); continue; } + if (!pressed) continue; // base/symbol keys only emit on press + + const uint8_t row = code / KB_COLS; + const uint8_t col = code % KB_COLS; + if (row >= KB_ROWS) continue; // a GPIO event outside the matrix, not a key + + char c = s_alt ? s_symbolMap[row][col] : s_keymap[row][col]; + if (c == '\0') continue; + // Caps Lock and a held Shift both mean "uppercase," base layer only. + if ((s_caps || s_shift_held) && !s_alt) c = (char)toupper((unsigned char)c); + ringPush((uint8_t)c); + } +} + +int pagerKeyboardReadKey() { + if (s_tail == s_head) return 0; + const uint8_t c = s_ring[s_tail]; + s_tail = (uint8_t)((s_tail + 1) & 15); + return c; +} + +void pagerKeyboardSetBacklight(uint8_t level) { + if (!s_bl_ready) { + ledcSetup(kKbBacklightPwmChannel, 1000 /* Hz */, 8 /* bits */); + ledcAttachPin(KB_BACKLIGHT, kKbBacklightPwmChannel); + s_bl_ready = true; + } + ledcWrite(kKbBacklightPwmChannel, level); +} + +bool pagerKeyboardAltHeld() { return s_alt; } + +void pagerKeyboardMarkAltUsed() { s_alt_used = true; } + +bool pagerKeyboardConsumeAltTap() { + if (!s_alt_tap_pending) return false; + s_alt_tap_pending = false; + return true; +} + +bool pagerKeyboardBackspaceHeld() { return s_backspace_held; } + +bool pagerKeyboardSpaceHeld() { return s_space_held; } + +bool pagerKeyboardConsumeAltShiftChord() { + if (!s_alt_shift_chord_pending) return false; + s_alt_shift_chord_pending = false; + return true; +} + +void pagerKeyboardToggleCaps() { s_caps = !s_caps; } + +bool pagerKeyboardConsumeAltBackspaceChord() { + if (!s_alt_backspace_chord_pending) return false; + s_alt_backspace_chord_pending = false; + return true; +} + +#endif diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h new file mode 100644 index 0000000..774e552 --- /dev/null +++ b/src/helpers/input/PagerKeyboard.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// T-LoRa Pager physical QWERTY keyboard: a TCA8418 I2C matrix controller (4 +// rows x 10 cols, addr 0x34) on the shared I2C bus (SDA 3 / SCL 2). Unlike +// the T-Deck's keyboard (a second MCU that resolves ASCII itself before we +// ever see a byte), the TCA8418 only reports raw row/col matrix events — the +// keymap + shift/sym/alt state machine lives HERE, so pagerKeyboardReadKey() +// produces the same final ASCII/control-code stream handleHwKey() already +// expects from the T-Deck; no UI-side changes needed to consume it. +// +// Threading: pagerKeyboardPoll() does the I2C read + keymap translation and +// must be called from a single, consistent context each tick (whichever task +// ends up owning it — wired in a later milestone; this board has no +// pre-existing shared-bus task the way the T-Deck's touch poll does). +// pagerKeyboardReadKey() only pops from a lock-free ring and is safe to call +// from the UI thread regardless of which context polls. +#if defined(HAS_PAGER_KEYBOARD) && defined(ESP32) + +#include + +/** Bring up the TCA8418 (I2C addr 0x34, 4x10 matrix). One-shot; safe to call + * even if the chip isn't present (poll()/readKey() just stay idle). */ +void pagerKeyboardBegin(); + +/** Drain any pending TCA8418 key events, translate through the keymap/shift- + * sym-alt state machine, and push resulting characters into the ring. + * Call from a single consistent context each tick. */ +void pagerKeyboardPoll(); + +/** Pop the next buffered key (ASCII/control code), or 0 if none. Safe from + * the UI thread. */ +int pagerKeyboardReadKey(); + +/** Set the keyboard backlight (0 = off, 1-255 = brightness). Applied + * immediately via LEDC PWM on GPIO 46 — unlike the T-Deck's I2C-based + * backlight, this is a plain GPIO and needs no deferred flush-on-next-poll. */ +void pagerKeyboardSetBacklight(uint8_t level); + +/** True while Alt is physically held (raw modifier state, tracked by + * pagerKeyboardPoll() — not a ring event, since Alt alone drives the symbol + * layer and is never itself pushed as a key). Lets other drivers build + * Alt+ shortcuts (e.g. the rotary encoder's Alt+turn tab switch) + * without a second, separate modifier concept. */ +bool pagerKeyboardAltHeld(); + +/** Mark the currently-held Alt as "used as a modifier" — call this when some + * other gesture (e.g. the rotary encoder's Alt+turn) consumes the hold, so + * releasing Alt afterward isn't also read as a solo tap by + * pagerKeyboardConsumeAltTap(). */ +void pagerKeyboardMarkAltUsed(); + +/** One-shot: true exactly once if Alt was pressed and released without being + * used as a modifier for anything else in between (no key typed, no + * pagerKeyboardMarkAltUsed() call) — a "solo tap", distinct from a + * symbol-layer or Alt+turn hold. Consumes the pending flag on read. */ +bool pagerKeyboardConsumeAltTap(); + +/** True while Backspace is physically held WITHOUT Alt (raw state, mirrors + * pagerKeyboardAltHeld()). A plain press still immediately ring-pushes '\b' + * as before; this is for callers that want to detect a long hold separately + * (e.g. UITask's press-and-hold "back" gesture). Alt+Backspace is a + * different gesture entirely (see pagerKeyboardConsumeAltBackspaceChord()) + * and never sets this or ring-pushes '\b'. */ +bool pagerKeyboardBackspaceHeld(); + +/** True while Space is physically held (raw state, mirrors + * pagerKeyboardBackspaceHeld()). A press still immediately ring-pushes ' ' + * as before, so normal typing is unaffected; this is for callers that want + * to detect a long hold separately (e.g. UITask's press-and-hold + * "lock screen" gesture). */ +bool pagerKeyboardSpaceHeld(); + +/** One-shot: true exactly once after Alt(Fn)+Shift is chorded (Shift pressed + * while Alt is held). The driver no longer decides what this chord DOES + * (that depends on UI state — is a text field being edited? — which this + * driver has no visibility into), it only reports that the chord happened; + * consumes the pending flag on read. See pagerKeyboardToggleCaps(). */ +bool pagerKeyboardConsumeAltShiftChord(); + +/** Toggle persistent Caps Lock. Callers gate this on the Alt+Shift chord + * above only applying while a text field is actually being edited. */ +void pagerKeyboardToggleCaps(); + +/** One-shot: true exactly once after Alt(Fn)+Backspace is chorded (Backspace + * pressed while Alt is held) — jumps Home, everywhere (editing a field or + * not), unlike the Alt+Shift chord above. Suppresses the normal Backspace + * press entirely: no '\b' ring-push, and pagerKeyboardBackspaceHeld() never + * reports held for this press, so it can't also fire the plain-Backspace + * hold-to-back/hold-to-unlock gestures. Consumes the pending flag on read. */ +bool pagerKeyboardConsumeAltBackspaceChord(); + +#endif diff --git a/src/helpers/ui/ST7796LCDDisplay.cpp b/src/helpers/ui/ST7796LCDDisplay.cpp new file mode 100644 index 0000000..c7bc9f2 --- /dev/null +++ b/src/helpers/ui/ST7796LCDDisplay.cpp @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ST7796LCDDisplay.h" + +#if defined(TLORA_PAGER) && defined(ESP32) + +// This panel's 222px glass is narrower than the ST7796 controller's native +// (320px) GRAM. TFT_eSPI's own ST7796_Rotation.h already has the fix +// (colstart=49/rowstart=49 depending on rotation, applied automatically by +// every setAddrWindow() call) -- but only when CGRAM_OFFSET is defined. +// Unlike ST7789_Defines.h (which self-defines it), ST7796_Defines.h does not. +// Without this flag the build succeeds but every frame renders shifted/ +// cropped by 49px with no error -- turn that into a build failure instead. +#if defined(ST7796_DRIVER) && !defined(CGRAM_OFFSET) +#error "ST7796LCDDisplay requires -D CGRAM_OFFSET=1 in this env's platformio.ini build_flags -- \ +this panel's 222px glass is narrower than the ST7796 controller's 320px GRAM, and TFT_eSPI's \ +ST7796_Rotation.h only applies the required 49px column/row offset when CGRAM_OFFSET is defined." +#endif + +#ifndef DISPLAY_ROTATION + #define DISPLAY_ROTATION 3 // landscape, matches the other boards' default +#endif +#ifndef DISPLAY_SCALE_X + #define DISPLAY_SCALE_X 1.0f // native res == logical res; kept for shape-parity with ST7789LCDDisplay +#endif +#ifndef DISPLAY_SCALE_Y + #define DISPLAY_SCALE_Y 1.0f +#endif + +bool ST7796LCDDisplay::begin() { + if (!_isOn) { + display.init(); // reads TFT_WIDTH/HEIGHT/ST7796_DRIVER/TFT_* pins from build flags; brings up its own SPI bus + // REQUIRED on this panel: TFT_eSPI's generic ST7796_Init.h never sends an + // inversion command (0x20/0x21) at all, leaving colours at the glass's own + // power-on default -- which on this pager's specific ST7796 panel batch is + // inverted (confirmed on hardware: white background / black logo instead + // of the intended dark theme, everything else -- centering, timing, + // encoder/keyboard wake -- working correctly). trail-mate's own bespoke + // ST7796 init table explicitly sends INVON (0x21) for this exact part, + // which is why their build never showed the problem. TFT_eSPI's + // invertDisplay() sends the same command (doubled, per its own comment, + // "otherwise it does not always work"). + display.invertDisplay(true); + // TEMPORARY M7 bring-up diagnostic (remove once the panel is confirmed + // solid): read the controller's own status registers back over SPI. A + // healthy post-init ST7796 reports RDDPM(0x0A)=0x9C (booster on, sleep + // out, display on) and RDDCOLMOD(0x0C)=0x55 (16bpp). All-0x00/0xFF here + // means the controller isn't responding at all (reset/power/SPI fault) — + // distinguishing "panel never initialized" from "panel fine, backlight + // dark", which look identical on the glass. + Serial.printf("[DISP] RDDPM=0x%02X MADCTL=0x%02X COLMOD=0x%02X RDDIM=0x%02X\n", + display.readcommand8(0x0A), display.readcommand8(0x0B), + display.readcommand8(0x0C), display.readcommand8(0x0D)); + display.setRotation(DISPLAY_ROTATION); + setLogicalSize((int)(display.width() / DISPLAY_SCALE_X), (int)(display.height() / DISPLAY_SCALE_Y)); + display.setAttribute(CP437_SWITCH, true); + display.fillScreen(TFT_BLACK); + display.setTextColor(TFT_WHITE); + display.setTextSize((uint8_t)(2 * DISPLAY_SCALE_X)); + + _backlight.begin(PIN_TFT_LEDA_CTL); // claims the pin itself (pinMode + initial LOW) -- don't also drive it here + setBrightness(_brightness_pct); + + _isOn = true; + } + return true; +} + +void ST7796LCDDisplay::turnOn() { ST7796LCDDisplay::begin(); } + +void ST7796LCDDisplay::turnOff() { + if (_isOn) { + const uint8_t keep = _brightness_pct; + setBrightness(0); // backlight chip actually off (single EN-low)... + _brightness_pct = keep; // ...but turnOn()'s begin() must restore the pre-off level, not 0 + display.writecommand(TFT_DISPOFF); // TFT_RST is on the XL9555 (board class owns it), so sleep via command here + _isOn = false; + } +} + +void ST7796LCDDisplay::clear() { display.fillScreen(TFT_BLACK); } + +void ST7796LCDDisplay::startFrame(Color bkg) { (void)bkg; display.fillScreen(TFT_BLACK); } + +void ST7796LCDDisplay::setTextSize(int sz) { display.setTextSize((uint8_t)(sz * DISPLAY_SCALE_X)); } + +void ST7796LCDDisplay::setColor(Color c) { + switch (c) { + case DisplayDriver::DARK: _color = TFT_BLACK; break; + case DisplayDriver::LIGHT: _color = TFT_WHITE; break; + case DisplayDriver::RED: _color = TFT_RED; break; + case DisplayDriver::GREEN: _color = TFT_GREEN; break; + case DisplayDriver::BLUE: _color = TFT_BLUE; break; + case DisplayDriver::YELLOW: _color = TFT_YELLOW; break; + case DisplayDriver::ORANGE: _color = TFT_ORANGE; break; + default: _color = TFT_WHITE; break; + } + display.setTextColor(_color); +} + +void ST7796LCDDisplay::setCursor(int x, int y) { + display.setCursor((int16_t)(x * DISPLAY_SCALE_X), (int16_t)(y * DISPLAY_SCALE_Y)); +} + +void ST7796LCDDisplay::print(const char* str) { display.print(str); } + +void ST7796LCDDisplay::fillRect(int x, int y, int w, int h) { + display.fillRect(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y, w * DISPLAY_SCALE_X, h * DISPLAY_SCALE_Y, _color); +} + +void ST7796LCDDisplay::drawRect(int x, int y, int w, int h) { + display.drawRect(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y, w * DISPLAY_SCALE_X, h * DISPLAY_SCALE_Y, _color); +} + +void ST7796LCDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { + // TFT_eSPI has no native XBM primitive either -- same manual bit-unpack loop as ST7789LCDDisplay. + uint8_t byteWidth = (w + 7) / 8; + for (int j = 0; j < h; j++) { + for (int i = 0; i < w; i++) { + uint8_t byte = bits[j * byteWidth + i / 8]; + if (byte & (0x80 >> (i & 7))) { + for (int dy = 0; dy < DISPLAY_SCALE_Y; dy++) { + for (int dx = 0; dx < DISPLAY_SCALE_X; dx++) { + display.drawPixel((int32_t)(x * DISPLAY_SCALE_X + i * DISPLAY_SCALE_X + dx), + (int32_t)(y * DISPLAY_SCALE_Y + j * DISPLAY_SCALE_Y + dy), _color); + } + } + } + } + } +} + +uint16_t ST7796LCDDisplay::getTextWidth(const char* str) { + return (uint16_t)(display.textWidth(str) / DISPLAY_SCALE_X); +} + +void ST7796LCDDisplay::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) { + if (!_isOn || !pixels || w <= 0 || h <= 0) return; + display.startWrite(); + display.setAddrWindow(x, y, w, h); // colstart/rowstart (CGRAM_OFFSET) applied here automatically + display.pushColors(const_cast(pixels), (uint32_t)(w * h)); // default swap=true matches LVGL's RGB565 buffers + display.endWrite(); +} + +void ST7796LCDDisplay::endFrame() { /* no-op: pushColors' own endWrite() already closed the SPI transaction */ } + +void ST7796LCDDisplay::setDisplayRotation(uint8_t r) { + display.setRotation(r); + setLogicalSize((int)(display.width() / DISPLAY_SCALE_X), (int)(display.height() / DISPLAY_SCALE_Y)); +} + +void ST7796LCDDisplay::setBrightness(uint8_t pct) { + if (pct > 100) pct = 100; + // AW9364LedDriver::setBrightness() is relative/circular pulse-stepping (no absolute-set wire + // command on this chip) -- round to the nearest of its 17 discrete steps (0..16), not floor, + // so e.g. 50% lands on step 8 rather than 7. + uint8_t steps = (uint8_t)(((uint16_t)pct * 16 + 50) / 100); + _backlight.setBrightness(steps); + _brightness_pct = pct; +} + +#endif diff --git a/src/helpers/ui/ST7796LCDDisplay.h b/src/helpers/ui/ST7796LCDDisplay.h new file mode 100644 index 0000000..f47a34b --- /dev/null +++ b/src/helpers/ui/ST7796LCDDisplay.h @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// T-LoRa Pager 480x222 (landscape) / 222x480 (native) ST7796 panel, driven +// via TFT_eSPI — the first real TFT_eSPI consumer in this codebase (the +// Heltec V4 env's TFT_eSPI lib_dep/-D flags are vestigial: nothing in the +// repo actually #includes TFT_eSPI.h; both Heltec V4 and T-Deck's +// ST7789LCDDisplay are built on Adafruit_GFX/Adafruit_ST7789 instead). Only +// the DisplayDriver-satisfying shape is mirrored from ST7789LCDDisplay, not +// any TFT_eSPI API calls — see the .cpp for the exact TFT_eSPI equivalents. +// +// Backlight is the AW9364 stepped pulse-dimmer IC (16 discrete steps), not a +// PWM-capable pin (TFT_BL=-1) — wrapped directly in here rather than as a +// separate Aw9364Backlight.{h,cpp}, since UITask.cpp has no display-class +// brightness hook to mirror today (brightness there is raw LEDC PWM on +// PIN_TFT_LEDA_CTL, which the AW9364 can't use). setBrightness()/ +// getBrightness() here are pct-based (0-100) so a later UITask.cpp wiring +// pass can call display.setBrightness(pct) directly, matching the existing +// pct-based Settings convention. +#if defined(TLORA_PAGER) && defined(ESP32) + +#include +#include +#include +#include + +class ST7796LCDDisplay : public DisplayDriver { + TFT_eSPI display; + AW9364LedDriver _backlight; + bool _isOn; + uint16_t _color; + uint8_t _brightness_pct; // exact requested pct, cached for getBrightness() (the 0-100 -> 0-16 -> 0-100 + // round trip through the AW9364's discrete steps is lossy) + +public: + // Native panel is 222x480 portrait; landscape (480x222) comes from MADCTL + // rotation, same approach as the other boards. These are placeholders -- + // begin()/setDisplayRotation() overwrite via setLogicalSize() using + // TFT_eSPI's own (rotation-aware) width()/height(). + static const int LOGICAL_WIDTH = 222; + static const int LOGICAL_HEIGHT = 480; + + ST7796LCDDisplay() : DisplayDriver(LOGICAL_WIDTH, LOGICAL_HEIGHT), _isOn(false), _color(0xFFFF), _brightness_pct(100) { } + + bool begin(); + + bool isOn() override { return _isOn; } + void turnOn() override; + void turnOff() override; + void clear() override; + void startFrame(Color bkg = DARK) override; + void setTextSize(int sz) override; + void setColor(Color c) override; + void setCursor(int x, int y) override; + void print(const char* str) override; + void fillRect(int x, int y, int w, int h) override; + void drawRect(int x, int y, int w, int h) override; + void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; + uint16_t getTextWidth(const char* str) override; + void endFrame() override; + + // Extra methods beyond DisplayDriver, called directly on the concrete type + // (same pattern as ST7789LCDDisplay's writePixelsRGB565/setDisplayRotation). + void writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels); + void setDisplayRotation(uint8_t r); + + // AW9364 brightness hook, pct in/pct out by design (see file header). + void setBrightness(uint8_t pct); + uint8_t getBrightness() const { return _brightness_pct; } +}; + +#endif diff --git a/src/main.cpp b/src/main.cpp index 3985277..d8ce032 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -702,6 +702,7 @@ void setup() { #ifdef DISPLAY_CLASS ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved + Serial.println("[BOOT] ui ready"); #endif board.onBootComplete(); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index e421cc9..abfc876 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -86,6 +86,14 @@ #ifndef PIN_I2S_DOUT #define PIN_I2S_DOUT 6 #endif +#elif defined(TLORA_PAGER) + #include // microSD (CS=21) on the shared radio/display SPI bus -- + // file manager browse + WAV picker only; no format support + // this pass (see fmSdTryMount()'s comment) + #define PIN_SD_CS PAGER_PIN_SD_CS // from TLoraPagerBoard.h, already visible via + // MyMesh.h -> target.h -> TLoraPagerBoard.h above + #include // pager ES8311 codec (notification tones + WAV playback) + #include "Es8311Codec.h" // PIN_I2S_MCLK/BCK/WS/DOUT/SDIN come from platformio.ini build flags #elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) #include // internal FAT partition (Tanmatsu 'locfd' / P4 'storage') #include // microSD on the P4-class boards' SDMMC slot 0; slot 1 = C6 radio @@ -106,12 +114,20 @@ #elif defined(HAS_M9_KEYBOARD) #include #endif + #if defined(HAS_PAGER_KEYBOARD) + #include + #endif + #if defined(HAS_PAGER_ENCODER) + #include + #endif #include "KeyboardLayouts.h" #include "i18n.h" #include "emoji_data.h" // baked Noto colour-emoji glyphs (emojiGlyphLookup) #include "qr_icon.h" // baked recolour-able QR glyph (qr_icon_dsc) for the Chats Share button #if defined(HAS_TANMATSU) #include // badge-bsp-backed DisplayDriver (P4) + #elif defined(TLORA_PAGER) + #include #elif defined(HAS_RAK_TAP_V2) #include // LovyanGFX FSPI on RAK Tap V2 #elif defined(HAS_TDISPLAY_P4) @@ -158,6 +174,8 @@ #endif #if defined(HAS_TANMATSU) extern TanmatsuDisplay display; + #elif defined(TLORA_PAGER) + extern ST7796LCDDisplay display; #elif defined(HAS_RAK_TAP_V2) || defined(HELTEC_LORA_V4_R8) extern LGFXDisplay display; #elif defined(HAS_TDISPLAY_P4) @@ -586,16 +604,18 @@ static void initTouchFontFallbacks() { g_font_14.fallback = &s_person_font14; } -// ---- T-Deck notification tones (I2S → MAX98357A speaker amp) ---- -// Simple synthesized beeps for UI feedback (message arrived, etc). NOT file -// playback — the T-Deck's amp is driven over I2S; we generate a short sine -// burst on the fly. The legacy genericBuzzer (RTTTL on a digital pin) doesn't -// apply here — that's for boards with a piezo on a GPIO, which the touch boards -// don't have. Gated to the T-Deck; the V4 has no speaker at all. +// ---- I2S notification sound (T-Deck MAX98357A amp / pager ES8311 codec) ---- +// Synthesized beeps and small WAV playback for UI feedback (message arrived, +// etc), both driven over I2S. The legacy genericBuzzer (RTTTL on a digital +// pin) doesn't apply here — that's for boards with a piezo on a GPIO (the +// Heltec V4), not an I2S speaker. WAV parsing and per-slot dispatch state +// below are shared; T-Deck and the pager each get their own I2S install/ +// tone/WAV functions, since the pager additionally drives an ES8311 codec +// over I2C that the T-Deck's plain MAX98357A DAC doesn't have. #if defined(HELTEC_LORA_V4_R8) static bool fmSdTryMount(); // V4-R8 microSD — fwd decl (defined in the mount-helper block below) #endif -#if defined(HAS_TDECK_GT911) +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) static constexpr int kI2sSampleRate = 16000; static constexpr i2s_port_t kI2sPort = I2S_NUM_0; // The tile fetcher's in-flight counter (defined later in the file). We skip @@ -604,11 +624,14 @@ static constexpr i2s_port_t kI2sPort = I2S_NUM_0; // already tight enough that tile downloads can OOM-reboot on their own. extern volatile uint16_t s_tile_fetch_pending; +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) +static bool fmSdTryMount(); // defined far below (microSD mount) +#endif +#if defined(HAS_TDECK_GT911) // I2S is installed ON DEMAND for the duration of a tone and uninstalled after. // Holding the driver resident permanently kept ~2 KB of internal DMA RAM, which // shrank the margin the tile-fetch worker relies on and made tile downloads // OOM-reboot. Transient install keeps steady-state internal RAM untouched. -static bool fmSdTryMount(); // defined far below (microSD mount, T-Deck) static bool tdeckAudioInstallRate(int rate) { // Heap pre-flight. i2s_driver_install ESP_ERROR_CHECKs its internal DMA + timer // allocations and abort()s the firmware on NO_MEM — this is the "esp_timer_create @@ -663,6 +686,84 @@ static void tdeckPlayToneRaw(int freq, int ms, int vol = 9000) { } i2s_zero_dma_buffer(kI2sPort); } +#endif // HAS_TDECK_GT911 + +#if defined(TLORA_PAGER) +// Same on-demand-install rationale as the T-Deck (above), plus this board's +// ES8311 codec: I2S clocks (incl. MCLK) must already be toggling before the +// codec's PLL will lock, so codec register writes happen after i2s_set_pin. +// The codec chip itself stays powered across chimes (its own begin() runs +// once, lazily, on first use) -- only the I2S driver and the codec's DAC +// power/format state (start()/suspend()) are cycled per playback, matching +// the amp's AMP_EN toggle in TLoraPagerBoard (see pagerNotifyTaskFn below). +static Es8311Codec s_pager_codec; +static bool s_pager_codec_begun = false; + +static bool pagerAudioInstallRate(int rate) { + if (heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) < 16 * 1024) return false; + i2s_config_t cfg = {}; + cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); + cfg.sample_rate = rate; + cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + cfg.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT; // mono synth/WAV buffer + cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; + cfg.intr_alloc_flags = 0; + cfg.dma_buf_count = 4; + cfg.dma_buf_len = 256; + cfg.use_apll = false; + cfg.tx_desc_auto_clear = true; + if (i2s_driver_install(kI2sPort, &cfg, 0, nullptr) != ESP_OK) return false; + i2s_pin_config_t pins = {}; + pins.mck_io_num = PIN_I2S_MCLK; + pins.bck_io_num = PIN_I2S_BCK; + pins.ws_io_num = PIN_I2S_WS; + pins.data_out_num = PIN_I2S_DOUT; + pins.data_in_num = I2S_PIN_NO_CHANGE; + if (i2s_set_pin(kI2sPort, &pins) != ESP_OK) { i2s_driver_uninstall(kI2sPort); return false; } + if (!s_pager_codec_begun) { + s_pager_codec_begun = s_pager_codec.begin(Wire, 0x18); + if (!s_pager_codec_begun) { i2s_driver_uninstall(kI2sPort); return false; } + } + if (!s_pager_codec.start((uint32_t)rate)) { i2s_driver_uninstall(kI2sPort); return false; } + return true; +} +static bool pagerAudioInstall() { return pagerAudioInstallRate(kI2sSampleRate); } +static void pagerAudioUninstall() { + s_pager_codec.setMute(true); + s_pager_codec.suspend(); + i2s_zero_dma_buffer(kI2sPort); + i2s_driver_uninstall(kI2sPort); +} + +// Render `freq` Hz for `ms` ms into the already-installed I2S as a 16-bit +// sine with a short fade-in/out so it doesn't click. Unlike the T-Deck's +// tdeckPlayToneRaw, loudness comes from the codec's hardware volume register +// (set by the caller before this runs), not software sample scaling, so +// there's no `vol` parameter here -- always render at a fixed safe level. +static void pagerPlayToneRaw(int freq, int ms) { + const int total = (kI2sSampleRate * ms) / 1000; + const int fade = total / 8 > 0 ? total / 8 : 1; + int16_t buf[128]; + int written_total = 0; + double phase = 0.0; + const double step = 2.0 * M_PI * (double)freq / (double)kI2sSampleRate; + const double amp0 = 22000.0; + while (written_total < total) { + int n = 0; + for (; n < 128 && written_total < total; ++n, ++written_total) { + double amp = amp0; + if (written_total < fade) amp *= (double)written_total / fade; + else if (written_total > total-fade) amp *= (double)(total - written_total)/fade; + buf[n] = (int16_t)(sin(phase) * amp); + phase += step; + if (phase > 2.0 * M_PI) phase -= 2.0 * M_PI; + } + size_t bw = 0; + i2s_write(kI2sPort, buf, n * sizeof(int16_t), &bw, pdMS_TO_TICKS(200)); + } + i2s_zero_dma_buffer(kI2sPort); +} +#endif // TLORA_PAGER // ---- Custom WAV notification playback (T-Deck I2S) ------------------------- // Stream a small PCM WAV (internal SPIFFS or "sd:"-prefixed SD) straight to the @@ -695,7 +796,9 @@ static bool wavParse(File& f, uint16_t* pch, uint32_t* prate, uint32_t* pdata) { static bool wavOpen(const char* prefpath, File& f) { if (!prefpath || !prefpath[0]) return false; fs::FS* fsp = &SPIFFS; const char* fp = prefpath; +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) // only the T-Deck/pager sound picker ever writes an "sd:"-prefixed pref if (!strncmp(prefpath, "sd:", 3)) { fsp = &SD; fp = prefpath + 3; fmSdTryMount(); } +#endif f = fsp->open(fp, FILE_READ); if (!f || f.isDirectory()) { if (f) f.close(); return false; } return true; @@ -706,6 +809,14 @@ static bool wavIsSupported(const char* prefpath) { f.close(); return ok; } + +// ---- Shared notification-task state (T-Deck + pager) ----------------------- +static volatile bool s_notify_playing = false; +static volatile int s_notify_slot = TOUCH_SND_MSG; // which per-event sound to play this round +static char s_notify_path[TOUCH_SOUND_PATH_MAXLEN] = {0}; // caller-resolved WAV path (avoid NVS in the task) +static volatile int s_notify_vol = 9000; // meaning is board-specific: T-Deck = software amplitude, pager = 0-100 hw volume pct + +#if defined(HAS_TDECK_GT911) static bool tdeckPlayWavFile(const char* prefpath, int vol) { File f; if (!wavOpen(prefpath, f)) return false; uint16_t ch=0; uint32_t rate=0, dlen=0; @@ -739,10 +850,6 @@ static bool tdeckPlayWavFile(const char* prefpath, int vol) { return true; } -static volatile bool s_notify_playing = false; -static volatile int s_notify_slot = TOUCH_SND_MSG; // which per-event sound to play this round -static char s_notify_path[TOUCH_SOUND_PATH_MAXLEN] = {0}; // caller-resolved WAV path (avoid NVS in the task) -static volatile int s_notify_vol = 9000; // amplitude, scaled from the volume pref // The chime body: install I2S, play the notes, uninstall. ~300 ms of blocking // i2s_write + driver setup/teardown — run on its own throwaway task (below). static void tdeckNotifyTaskFn(void* arg) { @@ -800,9 +907,96 @@ static void tdeckPreviewWavFile(const char* prefpath) { } #endif // HAS_TDECK_GT911 -// ---- Unified UI notification sound (T-Deck I2S speaker, Heltec V4 / Elecrow M9 piezo, -// T-Display P4 ES8311 codec) ---- -#if defined(HAS_TDECK_GT911) || defined(HELTEC_V4_BUZZER_PIN) || defined(THINKNODE_M9_BUZZER_PIN) || defined(HAS_TDISPLAY_P4) +#if defined(TLORA_PAGER) +static bool pagerPlayWavFile(const char* prefpath, int volPct) { + File f; if (!wavOpen(prefpath, f)) return false; + uint16_t ch=0; uint32_t rate=0, dlen=0; + if (!wavParse(f, &ch, &rate, &dlen)) { f.close(); return false; } + const uint32_t frameBytes = (uint32_t)ch * 2u; + const uint32_t maxBytes = rate * frameBytes * 6u; // ~6 s cap, matches the T-Deck path + if (dlen > maxBytes) dlen = maxBytes; + if (!pagerAudioInstallRate((int)rate)) { f.close(); return false; } + s_pager_codec.setVolumePercent((uint8_t)volPct); + s_pager_codec.setMute(false); + int16_t in[256], out[256]; + uint32_t remaining = dlen; + while (remaining >= frameBytes) { + size_t want = sizeof(in); + if (want > remaining) want = remaining - (remaining % frameBytes); + int got = f.read((uint8_t*)in, want); + if (got <= 0) break; + int frames = got / (int)frameBytes; + for (int i = 0; i < frames; ++i) { + // Downmix to mono -- no software gain (unlike the T-Deck path): the + // codec's hardware volume register, already set above, is doing that. + out[i] = (ch == 2) ? (int16_t)(((int32_t)in[2*i] + in[2*i+1]) / 2) : in[i]; + } + size_t bw = 0; + i2s_write(kI2sPort, out, (size_t)frames * sizeof(int16_t), &bw, pdMS_TO_TICKS(300)); + remaining -= (uint32_t)got; + } + i2s_zero_dma_buffer(kI2sPort); + pagerAudioUninstall(); + f.close(); + return true; +} + +// Mirrors tdeckNotifyTaskFn, bracketed with the amp's AMP_EN toggle (muted/ +// off at idle to save battery -- see TLoraPagerBoard::setAmpEnabled()). +static void pagerNotifyTaskFn(void* arg) { + (void)arg; + const int volPct = s_notify_vol; + board.setAmpEnabled(true); + bool played = (s_notify_path[0] && pagerPlayWavFile(s_notify_path, volPct)); + if (!played && pagerAudioInstall()) { + s_pager_codec.setVolumePercent((uint8_t)volPct); + s_pager_codec.setMute(false); + if (s_notify_slot == TOUCH_SND_MEN) { // @-mention: bright 3-note rising arpeggio + pagerPlayToneRaw(1318, 70); // E6 + pagerPlayToneRaw(1760, 70); // A6 + pagerPlayToneRaw(2349, 130); // D7 + } else if (s_notify_slot == TOUCH_SND_DM) { // direct message: distinct rising fifth + pagerPlayToneRaw(1047, 90); // C6 + pagerPlayToneRaw(1568, 120); // G6 + } else { // message: the original two-note chime + pagerPlayToneRaw(880, 90); // A5 + pagerPlayToneRaw(1318, 110); // E6 + } + pagerAudioUninstall(); + } + board.setAmpEnabled(false); + s_notify_playing = false; + vTaskDelete(nullptr); +} + +static void pagerPlayNotifySlot(int slot) { + if (s_tile_fetch_pending > 0) return; + if (s_notify_playing) return; + s_notify_slot = slot; + s_notify_vol = (int)touchPrefsGetSoundVolume(); // 0..100 -> the codec's hw volume register + touchPrefsGetSoundFile(slot, s_notify_path, sizeof s_notify_path); + s_notify_playing = true; + if (xTaskCreate(pagerNotifyTaskFn, "notify", 8192, nullptr, 3, nullptr) != pdPASS) { + s_notify_playing = false; + } +} +static void pagerPreviewWavFile(const char* prefpath) { + if (s_tile_fetch_pending > 0 || s_notify_playing) return; + strncpy(s_notify_path, prefpath, sizeof s_notify_path - 1); + s_notify_path[sizeof s_notify_path - 1] = '\0'; + s_notify_slot = TOUCH_SND_MSG; + s_notify_vol = (int)touchPrefsGetSoundVolume(); + s_notify_playing = true; + if (xTaskCreate(pagerNotifyTaskFn, "notify", 8192, nullptr, 3, nullptr) != pdPASS) + s_notify_playing = false; +} +#endif // TLORA_PAGER + +#endif // HAS_TDECK_GT911 || TLORA_PAGER + +// ---- Unified UI notification sound (T-Deck I2S / pager codec / Heltec V4 + Elecrow M9 +// piezo / T-Display P4 ES8311 codec) ---- +#if defined(HAS_TDECK_GT911) || defined(HELTEC_V4_BUZZER_PIN) || defined(TLORA_PAGER) || defined(THINKNODE_M9_BUZZER_PIN) || defined(HAS_TDISPLAY_P4) #define HAS_UI_SOUND 1 #endif @@ -889,6 +1083,8 @@ static inline void uiPlaySlot(int slot) { tdeckPlayNotifySlot(slot); #elif defined(HAS_TDISPLAY_P4) p4PlayNotifySlot(slot); // ES8311 codec chimes (same patterns as the T-Deck) +#elif defined(TLORA_PAGER) + pagerPlayNotifySlot(slot); #elif defined(HELTEC_V4_BUZZER_PIN) || defined(THINKNODE_M9_BUZZER_PIN) v4BuzzerBeep(slot == TOUCH_SND_MEN); // mention = higher trill; msg/DM = the lower chime #elif defined(HAS_TANMATSU) @@ -899,6 +1095,16 @@ static inline void uiPlaySlot(int slot) { // Notification chimes. uiPlayNotify = generic message slot; uiPlayMention = @-mention slot. static inline void uiPlayNotify() { uiPlaySlot(TOUCH_SND_MSG); } static inline void uiPlayMention() { uiPlaySlot(TOUCH_SND_MEN); } +// Preview an arbitrary WAV file (not yet saved to a slot) -- used by the +// sound picker's "play" button before the user commits to a choice. +static inline void uiPreviewWavFile(const char* path) { +#if defined(HAS_TDECK_GT911) + tdeckPreviewWavFile(path); +#elif defined(TLORA_PAGER) + pagerPreviewWavFile(path); +#endif + (void)path; +} #if defined(HAS_TANMATSU) // Defined in the HAS_TANMATSU apply block far below; forward-declared so the @@ -1074,6 +1280,17 @@ constexpr int TAB_LAST = 4; // row, leaving the row un-focusable so keyboard users could only open thread-settings, never the chat. #define NAV_HMOVE_FLAG LV_OBJ_FLAG_USER_2 +// An object whose keyboard-nav focus highlight should be an ACCENT-coloured +// tint (matching its own touch-press feedback) instead of navFocusCb's +// default white reverse-video fill. Used by app-drawer tiles (addAppTile): +// a solid white block over a small rounded icon chip reads as a glitch, not +// a highlight — while on the pager (no touch, so this IS the only feedback +// a user ever sees on these tiles) it needs to look intentional. Reuses the +// exact same COLOR_ACCENT tint the tile's own LV_STATE_PRESSED style already +// defines, so a keyboard/encoder-focused tile matches what a touch/trackball +// press already looks like on every board. +#define NAV_ACCENTFOCUS_FLAG LV_OBJ_FLAG_USER_3 + // ---- Chat overlay layout ---- constexpr int CHAT_HDR_H = 0; // in-chat header bar removed; thread name shows in the status bar #if CAP_LARGE_SCREEN @@ -2630,7 +2847,7 @@ static lv_obj_t* s_nav_objs[kNavMax] = { nullptr }; static lv_obj_t* s_nav_tabbar = nullptr; // the bottom tab bar (btnmatrix), added last to the group static bool s_nav_want_tabbar = false; // after switching tabs from the bar, refocus the bar static lv_obj_t* s_nav_styled = nullptr; // obj currently wearing the focus highlight -#if defined(HAS_TANMATSU) +#if defined(HAS_TANMATSU) || defined(TLORA_PAGER) static bool s_nav_show = true; // keyboard-only device: focus highlight is always visible #else static bool s_nav_show = false; // T-Deck: focus-visible — paint only while keyboard-navigating (hidden after a touch/click) @@ -2644,9 +2861,7 @@ static bool s_nav_suppress_scroll = false; static lv_obj_t* s_nav_focus_hint = nullptr; // one-shot: focus this object on the next rebuild (#45) static void goToTab(int idx); // (defined far below) tab switch + refresh static int getActiveTab(); // (defined below) current tabview index -#if defined(HAS_TANMATSU) -static void mapNudge(int dir); // (defined far below) Ctrl+Arrow map pan — 0=up 1=down 2=left 3=right -#endif +static void mapNudge(int dir); // (defined far below) map pan — 0=up 1=down 2=left 3=right static bool hwKeyDismissTopPopup(); // (defined far below) close the topmost modal/sheet static bool anyPopupOpen(); // (defined below) is any modal/sheet currently up static void closeChatPanel(LvChatPanel* p); // (defined far below) close an open chat/channel detail @@ -2867,6 +3082,15 @@ static void navFocusCb(lv_group_t* g) { lv_obj_set_style_shadow_width(f, 16, LV_PART_MAIN); lv_obj_set_style_shadow_spread(f, 2, LV_PART_MAIN); lv_obj_set_style_shadow_opa(f, LV_OPA_COVER, LV_PART_MAIN); + } else if (lv_obj_has_flag(f, NAV_ACCENTFOCUS_FLAG)) { + // App-drawer tile: a solid white reverse-fill over a small rounded icon + // chip reads as a glitch, not a highlight — use the SAME accent tint the + // tile's own LV_STATE_PRESSED style already applies on a touch/trackball + // press, so keyboard/encoder focus looks identical on every board instead + // of white-on-pager vs. accent-on-touch. + s_nav_sv.bg_c = s_nav_sv.bg_o = s_nav_sv.txt = false; // nothing to restore; navUnstyle just clears these + lv_obj_set_style_bg_color(f, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN); + lv_obj_set_style_bg_opa(f, LV_OPA_20, LV_PART_MAIN); } else { // Save the element's current local props, then apply the negative fill. s_nav_sv.bg_c = lv_obj_get_local_style_prop(f, LV_STYLE_BG_COLOR, &s_nav_sv.v_bg_c, LV_PART_MAIN) == LV_STYLE_RES_FOUND; @@ -3029,28 +3253,61 @@ static void navFindScrollableRec(lv_obj_t* o, bool up, lv_obj_t** best, long* be // scroll (or the focus isn't inside a scroll area), fall back to the biggest scrollable // page on the active screen — so the scroll keys ALWAYS move the page. static bool navMapZoomIfActive(bool zoom_in); // defined with the map zoom code below -static void navScrollFocused(bool up) { +// Returns the container it scrolled (or null if the map-zoom shortcut fired / nothing +// scrollable was found) — callers that need to relocate focus afterward use this instead +// of re-deriving the container themselves (see navRefocusFirstVisible()'s callers). +static lv_obj_t* navScrollFocused(bool up) { // On the Map tab the scroll keys zoom instead (F/C on the T-Deck, F/V on the // Tanmatsu — scroll-up = zoom in, like a mouse wheel on any map). The map has // nothing to scroll, so the keys were dead weight there (wyvern.red feedback). - if (navMapZoomIfActive(up)) return; + if (navMapZoomIfActive(up)) return nullptr; lv_obj_t* o = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; for (lv_obj_t* p = o; p; p = lv_obj_get_parent(p)) { if (!lv_obj_has_flag(p, LV_OBJ_FLAG_SCROLLABLE)) continue; const lv_coord_t room = up ? lv_obj_get_scroll_top(p) : lv_obj_get_scroll_bottom(p); if (room <= 0) continue; // nothing to scroll that way here — try the next ancestor navScrollBy(p, up); - return; + return p; } lv_obj_t* best = nullptr; long bestArea = 0; // fallback: scroll the page regardless of focus navFindScrollableRec(lv_scr_act(), up, &best, &bestArea); if (best) navScrollBy(best, up); + return best; +} +// Simple ancestor walk with no "room to scroll" gate — used only to relocate a container +// for a post-scroll refocus, where direction no longer matters. +static lv_obj_t* navNearestScrollableAncestor(lv_obj_t* start) { + for (lv_obj_t* p = start; p; p = lv_obj_get_parent(p)) + if (lv_obj_has_flag(p, LV_OBJ_FLAG_SCROLLABLE)) return p; + return nullptr; +} +// After a free-scroll with no explicit focus target, snap focus to whatever's nearest the +// top of the now-visible viewport — mirrors how a touch drag leaves focus alone until you +// tap something, but this device has no touch fallback, so plain NEXT/PREV needs a sane +// resume point instead of staying stuck on a focus that scrolled off-glass. +static void navRefocusFirstVisible(lv_obj_t* p) { + if (!p || !s_nav_group) return; + lv_area_t pa; lv_obj_get_coords(p, &pa); + const int n = s_nav_count < kNavMax ? s_nav_count : kNavMax; + lv_obj_t* best = nullptr; int32_t bestY = 0x7FFFFFFF; // lv_coord_t is 16-bit; would overflow this sentinel + for (int i = 0; i < n; i++) { + lv_obj_t* o = s_nav_objs[i]; + if (!o || !lv_obj_is_valid(o) || lv_obj_has_flag(o, LV_OBJ_FLAG_HIDDEN)) continue; + lv_area_t oa; lv_obj_get_coords(o, &oa); + if (oa.y1 < pa.y1 || oa.y1 > pa.y2) continue; // not at/below the container's own top edge + if (oa.y1 < bestY) { bestY = oa.y1; best = o; } + } + if (best) { s_nav_show = true; lv_group_focus_obj(best); } } // Small key hints over each menubar icon — shown only while keyboard nav is on. static void navMenubarKeysSync() { -#if defined(HAS_TANMATSU) - return; // Tanmatsu menubar uses the coloured F-key shapes, not letter hotkeys — no hints -#endif +#if defined(HAS_TANMATSU) || defined(TLORA_PAGER) + // Tanmatsu menubar uses the coloured F-key shapes, not letter hotkeys. The + // pager has no such optional nav-mode toggle to hint at either (s_kbd_nav is + // T-Deck's trackball-nav-vs-touch concept, CAP_TRACKBALL-only, so it isn't + // even declared here) — no hints. A plain `return` isn't enough since the + // body below still needs s_kbd_nav to exist at compile time; exclude it. +#else if (!g_lv.tabview) return; lv_obj_t* bar = lv_tabview_get_tab_btns(g_lv.tabview); if (!bar) return; @@ -3073,6 +3330,7 @@ static void navMenubarKeysSync() { lv_obj_clear_flag(s_navkey_hint[i], LV_OBJ_FLAG_HIDDEN); if (cw > 0) lv_obj_align(s_navkey_hint[i], LV_ALIGN_LEFT_MID, cw * i + cw / 2 - 15, 8); // bottom-left of the icon } +#endif } // Apply a captured key to the tab being remapped (Settings → Keyboard). static void navKeyCaptureApply(int key) { @@ -3152,6 +3410,23 @@ static lv_obj_t* navOpenDropdown() { return nullptr; } +#if defined(HAS_TANMATSU) || defined(TLORA_PAGER) +// Enter on a focused chat bubble = the same per-message action menu the T-Deck opens on a +// long-press (Copy / Info / …). Bubbles are the focusable leaves inside the chat's msgs +// container, so identify one by its parent. Returns true if it handled the Enter. Shared by +// Tanmatsu's navPump() (below) and the pager's handleHwKey() Enter branch — board-agnostic, +// only touches s_nav_group/navOpenChatPanel/plain lv_obj calls. +static bool navEnterBubble() { + lv_obj_t* foc = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; + LvChatPanel* cp = navOpenChatPanel(); + if (cp && cp->msgs && foc && lv_obj_is_valid(foc) && lv_obj_get_parent(foc) == cp->msgs) { + lv_event_send(foc, LV_EVENT_LONG_PRESSED, nullptr); + return true; + } + return false; +} +#endif + #if defined(HAS_TANMATSU) // bsp-input driven; on the T-Deck navFifo is fed from the trackball instead // ALT-accent picker (issue #129) — functions defined with the accent machinery // further down (they reuse the accent popup + kAccentSets); called from navPump. @@ -3185,19 +3460,6 @@ static void navArrowAction(uint32_t key) { } } -// Enter on a focused chat bubble = the same per-message action menu the T-Deck opens on a -// long-press (Copy / Info / …). Bubbles are the focusable leaves inside the chat's msgs -// container, so identify one by its parent. Returns true if it handled the Enter. -static bool navEnterBubble() { - lv_obj_t* foc = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; - LvChatPanel* cp = navOpenChatPanel(); - if (cp && cp->msgs && foc && lv_obj_is_valid(foc) && lv_obj_get_parent(foc) == cp->msgs) { - lv_event_send(foc, LV_EVENT_LONG_PRESSED, nullptr); - return true; - } - return false; -} - static void navPump() { if (!s_nav_queue && (bsp_input_get_queue(&s_nav_queue) != ESP_OK || !s_nav_queue)) return; // Fire-on-hold: trigger the F1/F4 long-press action the MOMENT the threshold passes while the @@ -3595,6 +3857,35 @@ static bool navTopHasVisibleChild(lv_obj_t* top) { return false; } +// True only at the top level — no modal/popup on lv_layer_top(), no settings detail +// sheet, no open chat/channel. Scopes the pager's Alt+encoder gesture (updatePagerEncoder): +// tab-switching only makes sense here; inside a nested scrollable screen the same +// gesture instead free-scrolls the page. +static bool navOnMainPage() { + if (navTopHasVisibleChild(lv_layer_top())) return false; + if (s_settings_sheet && lv_obj_is_valid(s_settings_sheet)) return false; + if (navOpenChatPanel()) return false; + return true; +} + +// Some fullscreen views (Terminal, File manager) build their OWN popups (command +// picker, prompts, action sheets, …) as separate lv_layer_top() siblings rather +// than nesting them inside the view — so with two visible top-layer children at +// once, collecting the whole top layer pulled in BOTH the view underneath and the +// popup on top of it, forcing nav to walk the entire hidden screen's controls +// before ever reaching the popup (reported: opening the terminal's command picker +// left focus on the terminal behind it). LVGL has no z-index — child order IS +// stacking order — so the last visible, non-skip child is the one actually on top; +// collect ONLY that subtree. Single-popup screens are unaffected (same one child). +static lv_obj_t* navTopFrontmostChild(lv_obj_t* top) { + int32_t n = (int32_t)lv_obj_get_child_cnt(top); + for (int32_t i = n - 1; i >= 0; i--) { + lv_obj_t* c = lv_obj_get_child(top, (uint32_t)i); + if (c && !lv_obj_has_flag(c, LV_OBJ_FLAG_HIDDEN) && !lv_obj_has_flag(c, NAV_SKIP_FLAG)) return c; + } + return top; // shouldn't happen (caller only calls this when navTopHasVisibleChild(top) is true) +} + static LvChatPanel* s_nav_prev_chat = nullptr; // last chat panel we focused (so we only auto-focus on open) // The global status bar has NAV_SKIP_FLAG so its passive glyphs (time / signal / battery) @@ -3642,7 +3933,7 @@ static void navMaybeRebuild() { // collect ITS controls + NO tab bar, so arrows stay inside the chat (don't reach the bar and switch // screens) and the composer can be focused for typing. LvChatPanel* chat = (useTop || on_settings) ? nullptr : navOpenChatPanel(); - lv_obj_t* root = useTop ? top : on_settings ? s_settings_sheet : (chat ? chat->overlay : scr); + lv_obj_t* root = useTop ? navTopFrontmostChild(top) : on_settings ? s_settings_sheet : (chat ? chat->overlay : scr); const bool on_page = !useTop && !on_settings && !chat; // #45: true = main tab content (the list that scrolls) lv_obj_t* tabbar = nullptr; if (!useTop && !on_settings && !chat && g_lv.tabview) { @@ -4231,6 +4522,7 @@ static int s_setup_step = 0; // 0 welcome / 1 name / 2 region static lv_obj_t* s_setup_name_ta = nullptr; static lv_obj_t* s_setup_region_list = nullptr; static int s_setup_region_sel = -1; // selected preset index, -1 = keep default +static lv_obj_t* s_setup_region_next_btn = nullptr; // so a keypad-nav region pick can jump focus straight to it static lv_obj_t* s_setup_ssid_ta = nullptr; // (legacy) Wi-Fi fields — the wizard's Wi-Fi step is now an info screen static lv_obj_t* s_setup_pwd_ta = nullptr; static void setupWizardOpen(); // fwd: re-trigger the flow (Device settings button) @@ -5473,6 +5765,19 @@ static void accentAltCb(lv_event_t* e) { // keystroke / a pick / hiding the keyboard. static lv_obj_t* s_accbox = nullptr; static lv_obj_t* s_accbox_ta = nullptr; // the field the box edits +#if defined(TLORA_PAGER) +// No touch on this board, and the cells below are NAV_SKIP_FLAG (excluded from +// the normal keyboard/encoder focus group by design, since touch boards pick +// them by tap) -- without this, the box is completely unreachable here. Fn +// (Alt)+Space jumps in (handleHwKey()); the rotary encoder then walks +// s_accbox_cells (updatePagerEncoder()); Enter (the encoder's own click) +// confirms via accentNavConfirm(); Backspace cancels. +static constexpr int kAccentNavMax = 8; // covers kAccentSets' largest set (7, 'a'/'A') +static lv_obj_t* s_accbox_cells[kAccentNavMax]; +static uint8_t s_accbox_cell_n = 0; +static bool s_accentnav_active = false; +static int s_accentnav_idx = 0; +#endif static const AccentSet* accentSetFor(char c) { for (const auto& s : kAccentSets) if (s.key == c) return &s; return nullptr; @@ -5480,7 +5785,30 @@ static const AccentSet* accentSetFor(char c) { static void accentBoxHide() { if (s_accbox) { lv_obj_del(s_accbox); s_accbox = nullptr; } s_accbox_ta = nullptr; +#if defined(TLORA_PAGER) + s_accentnav_active = false; + s_accbox_cell_n = 0; +#endif } +#if defined(TLORA_PAGER) +static void accentNavRestyle() { + for (uint8_t i = 0; i < s_accbox_cell_n; ++i) { + if (!s_accbox_cells[i]) continue; + lv_obj_set_style_bg_color(s_accbox_cells[i], + lv_color_hex((int)i == s_accentnav_idx ? COLOR_ACCENT : 0x1B2B3A), LV_PART_MAIN); + } +} +// Encoder's short-click while picking: fire the highlighted cell's own CLICKED +// binding (accentBoxCellCb, below) rather than duplicating its delete-base- +// letter + insert-variant + accentBoxHide() logic. +static void accentNavConfirm() { + if (s_accentnav_idx >= 0 && (uint8_t)s_accentnav_idx < s_accbox_cell_n && s_accbox_cells[s_accentnav_idx]) { + lv_event_send(s_accbox_cells[s_accentnav_idx], LV_EVENT_CLICKED, nullptr); + } else { + accentBoxHide(); + } +} +#endif static void accentBoxCellCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const char* variant = static_cast(lv_event_get_user_data(e)); @@ -5503,6 +5831,11 @@ static void accentBoxMaybeShow() { const AccentSet* set = accentSetFor(last[0]); if (!set) return; s_accbox_ta = ta; +#if defined(TLORA_PAGER) + s_accentnav_active = false; // fresh box -> Fn+Space (re-)arms nav mode + s_accentnav_idx = 0; + s_accbox_cell_n = set->n < kAccentNavMax ? set->n : (uint8_t)kAccentNavMax; +#endif const int cw = 34, ch = 40, gap = 4, pad = 6; s_accbox = lv_obj_create(lv_layer_top()); lv_obj_add_flag(s_accbox, NAV_SKIP_FLAG); // passive tap-only hint: never a keyboard-nav focus target (issue #22) @@ -5525,6 +5858,9 @@ static void accentBoxMaybeShow() { lv_obj_set_style_bg_color(c, lv_color_hex(0x1B2B3A), LV_PART_MAIN); lv_obj_set_style_bg_color(c, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(c, accentBoxCellCb, LV_EVENT_CLICKED, (void*)set->v[i]); +#if defined(TLORA_PAGER) + if (i < kAccentNavMax) s_accbox_cells[i] = c; +#endif lv_obj_t* l = lv_label_create(c); lv_label_set_text(l, set->v[i]); lv_obj_set_style_text_font(l, &g_font_16, LV_PART_MAIN); @@ -5561,10 +5897,47 @@ static lv_obj_t* s_mentionbox = nullptr; static lv_obj_t* s_mentionbox_ta = nullptr; static constexpr int k_mention_max = 6; static char s_mention_names[k_mention_max][32]; // kept alive for the cell callbacks +#if defined(TLORA_PAGER) +// No touch on this board, and the cells below are NAV_SKIP_FLAG (excluded from +// the normal keyboard/encoder focus group by design, since touch boards pick +// them by tap) -- without this, the box is completely unreachable here. +// Unlike the accent box (armed explicitly via Fn+Space), this list grabs the +// encoder the INSTANT it's shown (mentionBoxMaybeShow() sets +// s_mentionnav_active=true directly) -- see that function for why. The rotary +// encoder walks s_mentionbox_cells (updatePagerEncoder()); Enter (the +// encoder's own click) confirms via mentionNavConfirm(); Backspace cancels. +static lv_obj_t* s_mentionbox_cells[k_mention_max]; +static uint8_t s_mentionbox_cell_n = 0; +static bool s_mentionnav_active = false; +static int s_mentionnav_idx = 0; +#endif static void mentionBoxHide() { if (s_mentionbox) { lv_obj_del(s_mentionbox); s_mentionbox = nullptr; } s_mentionbox_ta = nullptr; +#if defined(TLORA_PAGER) + s_mentionnav_active = false; + s_mentionbox_cell_n = 0; +#endif } +#if defined(TLORA_PAGER) +static void mentionNavRestyle() { + for (uint8_t i = 0; i < s_mentionbox_cell_n; ++i) { + if (!s_mentionbox_cells[i]) continue; + lv_obj_set_style_bg_color(s_mentionbox_cells[i], + lv_color_hex((int)i == s_mentionnav_idx ? COLOR_ACCENT : 0x1B2B3A), LV_PART_MAIN); + } +} +// Encoder's short-click while picking: fire the highlighted cell's own CLICKED +// binding (mentionBoxCellCb, below) rather than duplicating its delete-partial +// + insert-name + mentionBoxHide() logic. +static void mentionNavConfirm() { + if (s_mentionnav_idx >= 0 && (uint8_t)s_mentionnav_idx < s_mentionbox_cell_n && s_mentionbox_cells[s_mentionnav_idx]) { + lv_event_send(s_mentionbox_cells[s_mentionnav_idx], LV_EVENT_CLICKED, nullptr); + } else { + mentionBoxHide(); + } +} +#endif // Index of the active mention's '@' in `text`, or -1. The active mention is the // last '@' that begins a word (start of text or after whitespace) with no // whitespace between it and the end (where the caret is). @@ -5619,6 +5992,16 @@ static bool mentionBoxMaybeShow() { } if (n == 0) return false; s_mentionbox_ta = ta; +#if defined(TLORA_PAGER) + // Unlike the accent box (armed explicitly via Fn+Space, since it pops up + // after almost every letter typed and must not steal focus from ongoing + // typing), the mention list only appears when the user has deliberately + // typed "@partial" looking for someone to pick -- so it grabs the encoder + // immediately, no arming gesture needed. + s_mentionnav_active = true; + s_mentionnav_idx = 0; + s_mentionbox_cell_n = (uint8_t)n; +#endif s_mentionbox = lv_obj_create(lv_layer_top()); lv_obj_add_flag(s_mentionbox, NAV_SKIP_FLAG); // passive, touch-only — never a keyboard-nav stop (issue #42) lv_obj_remove_style_all(s_mentionbox); @@ -5640,6 +6023,9 @@ static bool mentionBoxMaybeShow() { lv_obj_set_style_bg_color(b, lv_color_hex(0x1B2B3A), LV_PART_MAIN); lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_add_event_cb(b, mentionBoxCellCb, LV_EVENT_CLICKED, (void*)s_mention_names[i]); +#if defined(TLORA_PAGER) + if (i < k_mention_max) s_mentionbox_cells[i] = b; +#endif lv_obj_t* l = lv_label_create(b); lv_label_set_text_fmt(l, "@%s", s_mention_names[i]); lv_obj_set_style_text_font(l, &g_font_14, LV_PART_MAIN); @@ -5649,6 +6035,9 @@ static bool mentionBoxMaybeShow() { lv_obj_center(l); } lv_obj_set_size(s_mentionbox, boxw + 8, n * rowh + (n - 1) * 3 + 8); +#if defined(TLORA_PAGER) + mentionNavRestyle(); // highlight entry 0 immediately -- see the auto-arm note above +#endif // Place it above the composer, clamped above the keyboard (mirrors the accent box). lv_obj_update_layout(s_mentionbox); lv_area_t a; lv_obj_get_coords(ta, &a); @@ -6721,6 +7110,9 @@ static void openQuickReplyPicker(LvChatPanel* p) { lv_obj_add_event_cb(s_qr_sheet, qrSheetCloseCb, LV_EVENT_CLICKED, nullptr); // Bigger on the 800-px Tanmatsu panel; unchanged on the smaller boards. + // The pager's 480-wide landscape panel has plenty of spare width next to the + // old 220px single-column card (reported: half the screen sat empty) — give + // it 2 columns and a wider card instead of the shared 1-column sizing. #if CAP_LARGE_SCREEN const int card_w = PCW(210); const int btn_h = SC(32); // SC not PSC: the 1.7x PSC boost made this 6-row card taller than the screen @@ -6728,15 +7120,31 @@ static void openQuickReplyPicker(LvChatPanel* p) { const int title_h = SC(26); const int hint_h = SC(22); const int row_gap = SC(4); -#else - const int card_w = 220; - const int btn_h = 32; // 34→32: 6 macro rows have to fit in the - const int pad = 8; // visible area (298 px) below the status - const int title_h = 26; // bar — the old sizing produced a 302 px - const int hint_h = 22; // card that clipped behind the bar. + const int cols = 1; + const int col_gap = 0; +#elif defined(TLORA_PAGER) + const int card_w = 360; + const int btn_h = 32; + const int pad = 8; + const int title_h = 26; + const int hint_h = 22; const int row_gap = 4; + const int cols = 2; + const int col_gap = 8; +#else + const int card_w = 220; + const int btn_h = 32; // 34→32: 6 macro rows have to fit in the + const int pad = 8; // visible area (298 px) below the status + const int title_h = 26; // bar — the old sizing produced a 302 px + const int hint_h = 22; // card that clipped behind the bar. + const int row_gap = 4; + const int cols = 1; + const int col_gap = 0; #endif - int card_h = title_h + (TOUCH_QUICK_REPLY_COUNT + 1) * (btn_h + row_gap) + hint_h + pad; // +1: the GPS-position row + const int col_w = (card_w - 2 * pad - (cols - 1) * col_gap) / cols; + const int rows = (TOUCH_QUICK_REPLY_COUNT + cols - 1) / cols; // ceil, in case the macro count ever changes + const int gps_row_h = btn_h + row_gap; // GPS-position row: always full-width, sits above the macro grid + int card_h = title_h + gps_row_h + rows * (btn_h + row_gap) + hint_h + pad; if (card_h > sh - STATUSBAR_H - 8) card_h = sh - STATUSBAR_H - 8; // never taller than the visible area lv_obj_t* card = lv_obj_create(s_qr_sheet); lv_obj_remove_style_all(card); @@ -6757,7 +7165,6 @@ static void openQuickReplyPicker(LvChatPanel* p) { lv_obj_set_style_text_font(title, &g_font_14, LV_PART_MAIN); lv_obj_set_pos(title, 0, 0); - int y = title_h; #if defined(ESP32) { // --- My position (GPS) --- first row; greyed out without a live fix const bool fix = g_lv.task && g_lv.task->getGpsFix(); @@ -6766,7 +7173,7 @@ static void openQuickReplyPicker(LvChatPanel* p) { else snprintf(gbuf, sizeof gbuf, LV_SYMBOL_GPS " %s", TR("My position (no GPS fix)")); lv_obj_t* b = lv_btn_create(card); lv_obj_set_size(b, card_w - 2 * pad, btn_h); - lv_obj_set_pos(b, 0, y); + lv_obj_set_pos(b, 0, title_h); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(fix ? 0x1A1B1C : 0x0C0D0E), LV_PART_MAIN); if (fix) lv_obj_add_event_cb(b, qrGpsPickCb, LV_EVENT_CLICKED, nullptr); @@ -6778,28 +7185,28 @@ static void openQuickReplyPicker(LvChatPanel* p) { lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(fix ? COLOR_TEXT : COLOR_SUB), LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); - y += btn_h + row_gap; } for (int i = 0; i < TOUCH_QUICK_REPLY_COUNT; ++i) { char buf[TOUCH_QUICK_REPLY_MAXLEN]; int n = touchPrefsGetQuickReply(i, buf, sizeof(buf)); if (n <= 0) { strncpy(buf, "(empty)", sizeof(buf) - 1); buf[sizeof(buf)-1] = '\0'; } + const int col = i % cols, row = i / cols; lv_obj_t* b = lv_btn_create(card); - lv_obj_set_size(b, card_w - 2 * pad, btn_h); - lv_obj_set_pos(b, 0, y); + lv_obj_set_size(b, col_w, btn_h); + lv_obj_set_pos(b, col * (col_w + col_gap), title_h + gps_row_h + row * (btn_h + row_gap)); styleButton(b); lv_obj_set_style_bg_color(b, lv_color_hex(n > 0 ? 0x1A1B1C : 0x0C0D0E), LV_PART_MAIN); lv_obj_add_event_cb(b, qrPickCb, LV_EVENT_CLICKED, (void*)(intptr_t)i); lv_obj_t* lbl = lv_label_create(b); lv_label_set_text(lbl, buf); lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); - lv_obj_set_width(lbl, card_w - 2 * pad - 16); + lv_obj_set_width(lbl, col_w - 16); lv_obj_set_style_text_font(lbl, &g_font_12, LV_PART_MAIN); lv_obj_set_style_text_color(lbl, lv_color_hex(n > 0 ? COLOR_TEXT : COLOR_SUB), LV_PART_MAIN); lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 8, 0); - y += btn_h + row_gap; // match the gap used in card_h calc above } #endif + const int y = title_h + gps_row_h + rows * (btn_h + row_gap); // hint sits below the last row lv_obj_t* hint = lv_label_create(card); lv_label_set_text(hint, TR("Edit in Settings \xe2\x86\x92 Quick replies")); lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_SUB), LV_PART_MAIN); @@ -7015,7 +7422,7 @@ static void toggleMentionSoundCb(lv_event_t* e) { if (on) uiPlayMention(); #endif } -#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) +#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_TDISPLAY_P4) // Volume +/- step buttons (user_data = step, e.g. +10 / -10). Clamps 0..100, // updates the readout, previews. Simpler + more reliable than a drag slider. static lv_obj_t* s_vol_val_lbl = nullptr; @@ -7672,22 +8079,51 @@ static void saveRadioParamsCb(lv_event_t* e) { if (!silent) g_lv.task->showAlert(m, 1500); if (g_set_modal.tx_ta) { char v[8]; snprintf(v, sizeof v, "%d", tx); lv_textarea_set_text(g_set_modal.tx_ta, v); } // reflect the clamp in the field } + // Region scope — trimmed the same way it's persisted, so it can be compared below. + char region[TOUCH_REGION_SCOPE_MAXLEN] = {0}; + const bool has_region_ta = (g_set_modal.region_ta != nullptr); + if (has_region_ta) { + strncpy(region, lv_textarea_get_text(g_set_modal.region_ta), sizeof(region) - 1); + char* r = region; // trim so the stored name matches the key + while (*r == ' ' || *r == '\t') r++; + size_t rl = strlen(r); + while (rl && (r[rl-1]==' '||r[rl-1]=='\t'||r[rl-1]=='\n'||r[rl-1]=='\r')) r[--rl] = '\0'; + memmove(region, r, strlen(r) + 1); + } +#if defined(TLORA_PAGER) + // Blur auto-save fires on EVERY field defocus, including pure keyboard/encoder nav that + // never edits anything (no touch fallback on the pager — moving focus off a field IS a + // defocus). Skip the flash write + live radio SPI reconfigure when nothing actually + // changed, so tabbing through this row doesn't retrigger a several-hundred-ms-to- + // multi-second radio reinit per field — observed on hardware as escalating + // [STALL] ui:lvgl entries while navigating this screen with no edits made. Touch boards + // don't hit this (a field only blurs on a deliberate tap-out after an edit), so they + // keep the original always-save-on-blur behavior. + if (silent) { + NodePrefs* prefs = the_mesh.getNodePrefs(); + char cur_region[TOUCH_REGION_SCOPE_MAXLEN] = {0}; + if (has_region_ta) touchPrefsGetRegionScope(cur_region, sizeof(cur_region)); + const bool unchanged = prefs + && std::fabs(prefs->freq - freq) <= 0.002 + && std::fabs(prefs->bw - bw) <= 0.02 + && prefs->sf == (uint8_t)sf + && prefs->cr == (uint8_t)cr + && prefs->tx_power_dbm == (int8_t)tx + && std::fabs(static_cast(prefs->airtime_factor) - af) <= 0.005 + && (!has_region_ta || strcmp(cur_region, region) == 0); + if (unchanged) return; + } +#endif bool ok = g_lv.task->setRadioParams(freq, bw, static_cast(sf), static_cast(cr), static_cast(tx), af); // Region scope — independent of the freq/SF values above. Derive + persist the // flood-scope key from the typed "#region" (blank clears it back to unscoped), // and remember the display name for next time the form is shown. bool has_region = false; - if (g_set_modal.region_ta) { - char region[TOUCH_REGION_SCOPE_MAXLEN] = {0}; - strncpy(region, lv_textarea_get_text(g_set_modal.region_ta), sizeof(region) - 1); - char* r = region; // trim so the stored name matches the key - while (*r == ' ' || *r == '\t') r++; - size_t rl = strlen(r); - while (rl && (r[rl-1]==' '||r[rl-1]=='\t'||r[rl-1]=='\n'||r[rl-1]=='\r')) r[--rl] = '\0'; - the_mesh.setDefaultFloodScope(r); - touchPrefsSetRegionScope(r); - has_region = (r[0] != '\0'); + if (has_region_ta) { + the_mesh.setDefaultFloodScope(region); + touchPrefsSetRegionScope(region); + has_region = (region[0] != '\0'); } if (ok) { if (!silent) g_lv.task->showAlert(has_region ? TR("Radio + region set") : TR("Radio applied"), 1000); @@ -8836,7 +9272,7 @@ static void buildRadioSettings() { g_set_modal.freq_ta = mk_ta(fw, 0, "MHz", 15); g_set_modal.bw_ta = mk_ta(fw, fw + g, "kHz", 10); } - y += SC(36); + y += SC(44); // was SC(36): only left a 6px gap below the 30px-tall boxes, crowding the label below mk_label("SF / CR / TX / AF"); { const int g = 6, fw = (cw - 3 * g) / 4; // four equal fields filling the row @@ -9765,6 +10201,8 @@ static void lockOnScreenOffToggleCb(lv_event_t* e) { const char* unlock_hint = on ? TR("Locks when screen off\n(hold the d-pad to unlock)") : TR("Screen-off just dims"); #elif defined(HAS_TANMATSU) const char* unlock_hint = on ? TR("Locks when screen off\n(press Volume Down to unlock)") : TR("Screen-off just dims"); +#elif defined(TLORA_PAGER) + const char* unlock_hint = on ? TR("Locks when screen off\n(hold Backspace to unlock)") : TR("Screen-off just dims"); #else const char* unlock_hint = on ? TR("Locks when screen off\n(press the button to unlock)") : TR("Screen-off just dims"); #endif @@ -10317,15 +10755,15 @@ static void lockwallDisplayName(const char* path, char* out, int cap) { snprintf(out, cap, "%s%s", sd ? "SD: " : "", base); } static void openLockWallPickerCb(lv_event_t* e); // defined with the picker, below -#if CAP_SD && defined(HAS_TDECK_GT911) // custom WAV notification sounds need the T-Deck's I2S amp, not just SD +static void lockColorChosenCb(lv_event_t* e); +#endif // HAS_TDECK_GT911 || HAS_THINKNODE_M9 +#if CAP_SOUND_FILES // custom WAV notification sounds -- T-Deck (SD or SPIFFS) or pager (SPIFFS only) // Per-event notification-sound picker (Settings -> Sound). Mirrors the wallpaper picker. static lv_obj_t* s_snd_btn_lbl[3] = { nullptr, nullptr, nullptr }; static int s_snd_pending_slot = TOUCH_SND_MSG; // slot we're choosing a sound for static void soundDisplayName(const char* path, char* out, int cap); static void openSoundPickerCb(lv_event_t* e); // defined with the chooser, below #endif -static void lockColorChosenCb(lv_event_t* e); -#endif static void calibrateBatteryCb(lv_event_t* e); // defined with the battery helpers below #if defined(HAS_TDECK_KEYBOARD) @@ -10386,6 +10824,26 @@ static void kbBlPresetCb(lv_event_t* e) { // Off / 25 / 50 / 75 / Max: } #endif +#if defined(HAS_PAGER_KEYBOARD) +static lv_obj_t* s_pager_kbbl_lbl = nullptr; // Settings->Keyboard row caption (modal is a singleton) + +static const char* pagerKbBlModeText() { + return s_kb_bl_mode == 0 ? "Off" : (s_kb_bl_mode == 1 ? "On" : "Auto"); +} + +// Cycle off -> on -> auto -> off and persist -- same semantics as the Control +// Center's ccKbBacklightCb, duplicated rather than shared since CC is +// unreachable on this board (no touch launcher to open it) and the two update +// their UI differently (CC rebuilds the whole popup; this just relabels one +// button). +static void pagerKbBlCycleCb(lv_event_t* e) { + if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; + s_kb_bl_mode = (uint8_t)((s_kb_bl_mode + 1) % 3); + touchPrefsSetKbBacklight(s_kb_bl_mode); + if (s_pager_kbbl_lbl) lv_label_set_text(s_pager_kbbl_lbl, pagerKbBlModeText()); +} +#endif + static void buildDeviceSettings(int sec) { // One detail page per section: each block below is gated to its DSEC_* section // (skipped blocks don't advance y, so every page lays out from the top). @@ -10518,7 +10976,7 @@ static void buildDeviceSettings(int sec) { lv_obj_add_event_cb(sw, toggleMentionSoundCb, LV_EVENT_VALUE_CHANGED, nullptr); y += LV_MAX(34, rh + 12); } -#if CAP_SD && defined(HAS_TDECK_GT911) // WAV notification-sound rows (T-Deck's I2S amp, not just SD) +#if CAP_SOUND_FILES // WAV notification-sound rows (file-browsing sound picker, SD or SPIFFS) // Per-event notification sound files. Each slot: built-in chime, or a 16-bit // PCM WAV from /sounds/ (internal) or the SD card. Empty = built-in. { @@ -10545,8 +11003,8 @@ static void buildDeviceSettings(int sec) { } } #endif -#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) - // Volume with - / + step buttons (T-Deck I2S + Tanmatsu/P4 codec; the V4 piezo can't vary volume). +#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_TDISPLAY_P4) + // Volume with - / + step buttons (T-Deck I2S / Tanmatsu / pager / P4 codec; the V4 piezo can't vary volume). { lv_obj_t* vl = lv_label_create(body); lv_label_set_text(vl, TR("Volume")); @@ -10837,6 +11295,23 @@ static void buildDeviceSettings(int sec) { } y += SC(38); } +#elif defined(HAS_PAGER_KEYBOARD) + /* Keyboard backlight: off / on / auto (lit while typing, dark after ~3 s idle). + No brightness slider here (unlike the T-Deck) -- this board's backlight is a + simple on/off strip, so there's nothing to dial in beyond the mode. */ + { + y += settingsRowLabel(body, y, 0, "Keyboard backlight", COLOR_SUB, &g_font_12, 0) + 4; + lv_obj_t* b = lv_btn_create(body); + lv_obj_set_size(b, lv_pct(100), SC(34)); + lv_obj_set_pos(b, 2, y); + styleButton(b); + lv_obj_add_event_cb(b, pagerKbBlCycleCb, LV_EVENT_CLICKED, nullptr); + s_pager_kbbl_lbl = lv_label_create(b); + lv_label_set_text(s_pager_kbbl_lbl, pagerKbBlModeText()); + lv_obj_center(s_pager_kbbl_lbl); + y += SC(42); + y += settingsRowLabel(body, y, 0, "tap to cycle off / on / auto", COLOR_SUB, &g_font_12, 0) + 2; + } #endif /* Secondary keyboards (multi-select). Switch on any layouts you want in the rotation; a double-tap of SPACE on the physical keyboard cycles @@ -16114,8 +16589,8 @@ static char s_fm_path[160] = {0}; // current dir within s_fm_fs (e.g. // a generic fs::FS*; only &SD is real microSD I/O (Internal = SPIFFS). Browsing // (fmRefresh) and the file open/save paths call this; mutations re-list via // fmRefresh, so they blip the LED too. -#if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) -static inline bool fmIsSd(fs::FS* fs) { return fs == &SD; } // Arduino SD (T-Deck/M9 LoRa bus, V4-R8 TFT bus) +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) +static inline bool fmIsSd(fs::FS* fs) { return fs == &SD; } // Arduino SD (T-Deck/pager/M9 LoRa bus, V4-R8 TFT bus) #elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) static inline bool fmIsSd(fs::FS* fs) { return fs == &SD_MMC; } // microSD on SDMMC slot 0 #else @@ -17238,19 +17713,39 @@ static void fmFmtSize64(uint64_t bytes, char* out, size_t outsz) { else snprintf(out, outsz, "%.1f GB", bytes / (1024.0 * 1024 * 1024)); } -#if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // microSD mount/format helpers — Arduino SD (T-Deck/M9 LoRa bus, V4-R8 TFT bus) -// Mount the microSD on the shared LoRa SPI bus. Safe to call repeatedly (no-op +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) // microSD mount/format helpers — Arduino SD on the shared SPI bus +// One shared-SPI accessor per board: the T-Deck/M9 expose their pre-begun SPIClass +// via tdeckSharedSPI()/m9SharedSPI(); the V4-R8's microSD shares its TFT FSPI bus +// (heltecV4R8SharedSPI()); the pager's radio+display already share TFT_eSPI's own +// instance directly (variants/lilygo_tlora_pager/target.cpp), so reuse that the +// same way. +#if defined(TLORA_PAGER) +static inline SPIClass* sdSharedSPI() { return &TFT_eSPI::getSPIinstance(); } +// Card-detect via the XL9555 expander (PAGER_EXPAND_SD_DET). Polarity is INVERTED +// from what you'd guess — confirmed against LilyGoLib's own LilyGo_LoRa_Pager.cpp +// installSD() (`if (io.digitalRead(EXPANDS_SD_DET)) return false;`): HIGH = no card +// seated, LOW = card present. Skip the whole mount ladder when no card is seated, +// so opening the File Manager / WAV picker on a bare pager doesn't grind through +// the ladder's worst-case delay on every call. +static inline bool pagerSdCardPresent() { + return board.io_expander.digitalRead(PAGER_EXPAND_SD_DET) == LOW; +} +#elif defined(HELTEC_LORA_V4_R8) +static inline SPIClass* sdSharedSPI() { return heltecV4R8SharedSPI(); } // V4-R8: micro-SD shares the TFT FSPI bus (CS=3) +#elif defined(HAS_THINKNODE_M9) +static inline SPIClass* sdSharedSPI() { return m9SharedSPI(); } +#else +static inline SPIClass* sdSharedSPI() { return tdeckSharedSPI(); } +#endif +// Mount the microSD on its shared SPI bus. Safe to call repeatedly (no-op // once mounted). SD.begin's internal spi.begin() is a no-op because the bus is -// already initialised by the radio, so the radio's pins are untouched. +// already initialised by the radio/display, so those pins are untouched. static bool fmSdTryMount() { if (s_sd_mounted) return true; -#if defined(HAS_TDECK_GT911) - SPIClass* spi = tdeckSharedSPI(); -#elif defined(HELTEC_LORA_V4_R8) - SPIClass* spi = heltecV4R8SharedSPI(); // V4-R8: micro-SD shares the TFT FSPI bus (CS=3) -#else - SPIClass* spi = m9SharedSPI(); +#if defined(TLORA_PAGER) + if (!pagerSdCardPresent()) return false; #endif + SPIClass* spi = sdSharedSPI(); if (!spi) return false; // Cold microSD cards — especially the first mount after boot — often fail // the initial SD.begin and historically only recovered after a physical @@ -17316,7 +17811,7 @@ static void fmSdClickCb(lv_event_t* e) { if (s_sd_mounted) fmOpenStorage(&SD, "SD", "/"); } -#endif // HAS_TDECK_GT911 || HAS_THINKNODE_M9 (microSD mount helpers; the busy overlays below are generic LVGL) +#endif // HAS_TDECK_GT911 || TLORA_PAGER || HAS_THINKNODE_M9 || HELTEC_LORA_V4_R8 (microSD mount helpers; the busy overlays below are generic LVGL) // Full-screen "busy" notice (copy/move/format). Pure LVGL — used by the generic paste path too. static void fmShowBusyOverlay(const char* msg) { @@ -17941,7 +18436,7 @@ static bool fmIsImage(const char* name) { !strcasecmp(dot, ".jpeg") || !strcasecmp(dot, ".sjpg") || !strcasecmp(dot, ".bmp"); } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) static bool fmIsAudio(const char* name) { if (!name) return false; const char* dot = strrchr(name, '.'); @@ -18111,7 +18606,7 @@ static void fmSetWallpaperCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(TR("Lock wallpaper set"), 1300); } -#if CAP_SD && defined(HAS_TDECK_GT911) +#if CAP_SOUND_FILES // ---- .wav -> notification-sound chooser (opened from the File Manager) ------ static char s_fm_snd_path[208] = {0}; static bool s_fm_snd_on_sd = false; @@ -18126,7 +18621,7 @@ static void fmSndPlayCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_fm_snd_path[0]) return; char pref[TOUCH_SOUND_PATH_MAXLEN]; fmSndBuildPref(pref, sizeof pref); if (!wavIsSupported(pref)) { if (g_lv.task) g_lv.task->showAlert(TR("Unsupported WAV (need 16-bit PCM)"), 2200); return; } - tdeckPreviewWavFile(pref); + uiPreviewWavFile(pref); } static void fmSndSetCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_fm_snd_path[0]) return; @@ -18186,7 +18681,7 @@ static void fmOpenAudio(const char* name) { lv_obj_t* cl = lv_label_create(close); lv_label_set_text(cl, LV_SYMBOL_CLOSE); tanCloseRed(cl); lv_obj_set_style_text_font(cl, &g_font_12, LV_PART_MAIN); lv_obj_center(cl); } -#endif // HAS_TDECK_GT911 (.wav notification-sound chooser) +#endif // HAS_TDECK_GT911 || TLORA_PAGER (.wav notification-sound chooser) static void fmOpenImage(const char* name) { if (!s_fm_fs || !name || !name[0]) return; @@ -18344,7 +18839,7 @@ static void fmRowClickCb(lv_event_t* e) { FmRowData* rd = (FmRowData*)lv_obj_get_user_data(lv_event_get_target(e)); if (!rd) return; if (rd->isdir) fmEnterDir(rd->name); -#if CAP_SD && defined(HAS_TDECK_GT911) +#if CAP_SOUND_FILES else if (fmIsAudio(rd->name)) fmOpenAudio(rd->name); // .wav -> notification-sound chooser #endif else if (fmIsImage(rd->name)) fmOpenImage(rd->name); // images -> read-only viewer @@ -18589,6 +19084,18 @@ static void fmShowRoots() { fmStyleRow(sd, COLOR_SUB); lv_obj_add_event_cb(sd, fmSdMountOrFormatCb, LV_EVENT_CLICKED, nullptr); } +#elif defined(TLORA_PAGER) // microSD row — browse only this pass, no format (see fmSdTryMount()) + // The card-detect line makes "not present" unambiguous, unlike the T-Deck (no detect + // pin at all) -- so unlike its always-shown "tap to mount/format" fallback row, a bare + // pager just shows no SD row at all rather than a row that can never succeed. + if (pagerSdCardPresent() && (s_sd_mounted || millis() >= s_sd_retry_after_ms) && fmSdTryMount()) { + char sdl[48], cs[16]; + fmFmtSize64(s_sd_size, cs, sizeof cs); + snprintf(sdl, sizeof sdl, TR("SD card %s"), cs); + lv_obj_t* sd = lv_list_add_btn(s_fm_list, LV_SYMBOL_SD_CARD, sdl); + fmStyleRow(sd, COLOR_TEXT); + lv_obj_add_event_cb(sd, fmSdClickCb, LV_EVENT_SHORT_CLICKED, nullptr); + } #endif #if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) // microSD (SDMMC slot 0). Probe outside the mount-backoff window so an absent card doesn't grind @@ -20860,9 +21367,10 @@ static void makeHome(lv_obj_t* tab) { lv_obj_set_ext_click_area(s_home_chart_legend, 8); lv_obj_add_event_cb(s_home_chart_legend, homeChartClickedCb, LV_EVENT_CLICKED, nullptr); -#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(HAS_RAK_TAP_V2) || defined(HAS_THINKNODE_M9) - // Landscape (T-Deck / Tanmatsu / RAK Tap V2 / M9): the right column holds Advert + Terminal + Files + Apps, - // so the chart must stop short of that strip — else it draws over the buttons. +#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) || defined(HAS_RAK_TAP_V2) || defined(HAS_THINKNODE_M9) + // Landscape (T-Deck / Tanmatsu / pager / RAK Tap V2 / M9): the right column holds + // Advert + Terminal + Files/Apps + Control, so the chart must stop short of that + // strip — else it draws over the buttons. const int chart_w = home_land ? (cw - RSTRIP) : cw; #else const int chart_w = cw; @@ -25361,13 +25869,13 @@ static void mapCanvasEventCb(lv_event_t* e) { refreshMapInfoLabel(); } -#if defined(HAS_TANMATSU) -// Keyboard pan (Ctrl+Arrow on the Map tab). Mirrors the drag-release math in -// mapCanvasEventCb: synthesize a pixel delta of ~1/4 the visible span in the -// arrow direction, convert it through the same world-px ↔ lat/lon helpers (so -// the lon step automatically scales with the current zoom's degrees-per-pixel) -// and re-render. dir: 0=up(north,+lat) 1=down(south,−lat) 2=left(west,−lon) -// 3=right(east,+lon). +#if defined(HAS_TANMATSU) || defined(TLORA_PAGER) +// Keyboard pan (Ctrl+Arrow on Tanmatsu / WASD on the pager, both on the Map +// tab). Mirrors the drag-release math in mapCanvasEventCb: synthesize a pixel +// delta of ~1/4 the visible span in the arrow direction, convert it through +// the same world-px ↔ lat/lon helpers (so the lon step automatically scales +// with the current zoom's degrees-per-pixel) and re-render. dir: 0=up(north, +// +lat) 1=down(south,−lat) 2=left(west,−lon) 3=right(east,+lon). static void mapNudge(int dir) { if (!s_map_canvas) return; // No center yet (no GPS / location) → nothing to pan around. @@ -25390,7 +25898,7 @@ static void mapNudge(int dir) { renderMapMarkers(); refreshMapInfoLabel(); } -#endif // HAS_TANMATSU (mapNudge) +#endif // HAS_TANMATSU || TLORA_PAGER (mapNudge) // ----- Zoom + recenter ----- // @@ -30890,6 +31398,357 @@ static void updateTrackball(unsigned long now) { } #endif +#if defined(HAS_PAGER_ENCODER) || defined(HAS_PAGER_KEYBOARD) +// "Back", extending the T-Deck/Tanmatsu back-key ladder: a popup/sheet on top +// closes first, then an open chat/channel detail, then Home (the pager has no +// dedicated Home hotkey and the bottom tab bar isn't a nav-group target, so a +// bare main tab otherwise had no way back to Home), else plain ESC. Shared by +// the rotary encoder's long-press (updatePagerEncoder) and the keyboard's +// Backspace-hold alternative (updatePagerBackspaceHold) so both agree exactly. +static void pagerNavGoBack() { + if (anyPopupOpen()) hwKeyDismissTopPopup(); + else if (LvChatPanel* cp = navOpenChatPanel()) closeChatPanel(cp); + else if (getActiveTab() != HOME_TAB_INDEX) navGoToMainTab(HOME_TAB_INDEX); + else navPushTap(LV_KEY_ESC); +} +#endif + +#if defined(HAS_PAGER_KEYBOARD) +// Backspace press-and-hold = the same "back" as the encoder's long-press — +// an alternative for users wary of wearing out the rotary encoder. Same +// 1000 ms state machine as the encoder's click handling below; a short tap +// still deletes a character as normal (PagerKeyboard ring-pushes '\b' on +// press, unchanged). +static void updatePagerBackspaceHold(unsigned long now) { + if (g_lv.task && g_lv.task->isScreenOff()) return; + const bool held = pagerKeyboardBackspaceHeld(); + static constexpr uint32_t kLongPressMs = 1000; + static bool s_was_held = false; + static uint32_t s_press_start = 0; + static bool s_long_fired = false; + + if (held && !s_was_held) { + s_press_start = now; + s_long_fired = false; + } else if (held && !s_long_fired && (now - s_press_start) >= kLongPressMs) { + pagerNavGoBack(); + if (g_lv.task) g_lv.task->noteUserInput(); + s_long_fired = true; + } + s_was_held = held; +} + +// Orange/Alt key, tapped alone (not held as a symbol-layer modifier or for the +// encoder's Alt+turn) = the same "next field" as one rotary NEXT detent. Call +// once per loop tick while the screen is on; screen-off handling discards any +// pending tap instead (see loop()'s HAS_PAGER_KEYBOARD branch) so a stray tap +// picked up while idle-dimmed can't fire the moment the screen wakes. +static void updatePagerAltTapNext() { + if (!pagerKeyboardConsumeAltTap()) return; + navPushTap(LV_KEY_NEXT); + if (g_lv.task) g_lv.task->noteUserInput(); +} + +// Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has +// no UI visibility): toggles Caps Lock while actually editing a text field +// (the field is where "Caps Lock" means anything) and is a deliberate no-op +// everywhere else (Home-jump duty moved to Alt+Backspace below, so there's no +// longer a reason to make this chord do anything outside a field — Caps Lock +// silently flipping with no field to see it in was reported as +// surprising/purposeless before this split). +static void updatePagerAltShiftChord() { + if (!pagerKeyboardConsumeAltShiftChord()) return; + lv_obj_t* ta_focused = lv_keyboard_get_textarea(g_lv.keyboard); + lv_obj_t* ta = (ta_focused && s_nav_group && lv_group_get_focused(s_nav_group) == ta_focused) ? ta_focused : nullptr; + if (!ta) return; + pagerKeyboardToggleCaps(); + if (g_lv.task) g_lv.task->noteUserInput(); +} + +// Alt(Fn)+Backspace chord: jump straight Home, unconditionally -- unlike +// Alt+Shift above, this one is NOT context-dependent (works whether or not a +// field is being edited, per explicit request). Fires instead of a normal +// Backspace press: PagerKeyboard.cpp suppresses the '\b' ring-push and the +// hold-to-back/hold-to-unlock tracking entirely for an Alt-held Backspace +// press, so there's no double-action to guard against here. +static void updatePagerAltBackspaceChord() { + if (!pagerKeyboardConsumeAltBackspaceChord()) return; + // The accent/@-mention pickers live on lv_layer_top(), outside the tab + // content navGoToMainTab() switches away from -- close them explicitly + // first so a stray overlay doesn't keep floating over the Home screen. + accentBoxHide(); + mentionBoxHide(); + navGoToMainTab(HOME_TAB_INDEX); + if (g_lv.task) g_lv.task->noteUserInput(); +} + +// ---- Spacebar hold-to-lock (mirrors the T-Deck's spacebar lock) ------------- +// The T-Deck keyboard can't detect a real key-up, so it fakes a hold with a +// press-then-1s-countdown; the TCA8418 here reports genuine press/release, so +// this is an actual hold, timed the same as pagerNavGoBack()'s gestures, with +// a live progress bar (T-Deck shows a 3-2-1 countdown instead since its lock +// isn't a real hold). +static lv_obj_t* s_pager_locking_popup = nullptr; +static lv_obj_t* s_pager_locking_bar = nullptr; + +static void pagerLockingPopupHide() { + if (s_pager_locking_popup) { popupClose(&s_pager_locking_popup); s_pager_locking_bar = nullptr; } +} +static void pagerLockingPopupShow() { + if (s_pager_locking_popup) return; + s_pager_locking_popup = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(s_pager_locking_popup); + lv_obj_set_size(s_pager_locking_popup, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr) - STATUSBAR_H); + lv_obj_set_pos(s_pager_locking_popup, 0, STATUSBAR_H); + lv_obj_set_style_bg_color(s_pager_locking_popup, lv_color_black(), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_pager_locking_popup, LV_OPA_60, LV_PART_MAIN); + lv_obj_clear_flag(s_pager_locking_popup, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* card = lv_obj_create(s_pager_locking_popup); + lv_obj_remove_style_all(card); + lv_obj_set_size(card, 180, 74); + lv_obj_center(card); + lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(card, 12, LV_PART_MAIN); + lv_obj_set_style_border_color(card, lv_color_hex(0x18191A), LV_PART_MAIN); + lv_obj_set_style_border_width(card, 1, LV_PART_MAIN); + lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* t = lv_label_create(card); + lv_label_set_text(t, TR("Locking\xE2\x80\xA6")); // Locking… + lv_obj_set_style_text_color(t, lv_color_hex(COLOR_TEXT), LV_PART_MAIN); + lv_obj_set_style_text_font(t, &g_font_16, LV_PART_MAIN); + lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 8); + + s_pager_locking_bar = lv_bar_create(card); + lv_obj_set_size(s_pager_locking_bar, 140, 8); + lv_obj_align(s_pager_locking_bar, LV_ALIGN_BOTTOM_MID, 0, -14); + lv_obj_set_style_bg_color(s_pager_locking_bar, lv_color_hex(0x2A2D31), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_pager_locking_bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_color(s_pager_locking_bar, lv_color_hex(COLOR_ACCENT), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_pager_locking_bar, LV_OPA_COVER, LV_PART_INDICATOR); + lv_bar_set_range(s_pager_locking_bar, 0, 1000); + lv_bar_set_value(s_pager_locking_bar, 0, LV_ANIM_OFF); +} + +static void updatePagerSpaceHold(unsigned long now) { + // Never engage mid-typing (space just types normally there) or once already + // locked/off (nothing left to do -- updatePagerBackspaceUnlockHold owns the + // reverse direction). + if ((g_lv.task && (g_lv.task->isScreenOff() || g_lv.task->isManualLock())) || navFocusedTextarea()) { + pagerLockingPopupHide(); + return; + } + const bool held = pagerKeyboardSpaceHeld(); + static constexpr uint32_t kLongPressMs = 1000; + static bool s_was_held = false; + static uint32_t s_press_start = 0; + static bool s_long_fired = false; + + if (held && !s_was_held) { + s_press_start = now; + s_long_fired = false; + } else if (held && !s_long_fired) { + const uint32_t elapsed = now - s_press_start; + // A bare space tap is otherwise a total no-op here (unfocused, no field) -- + // hold off showing the popup for a beat so an ordinary quick tap doesn't + // flash it on and off. + if (elapsed >= 150) { + pagerLockingPopupShow(); + if (s_pager_locking_bar) lv_bar_set_value(s_pager_locking_bar, elapsed > kLongPressMs ? kLongPressMs : elapsed, LV_ANIM_OFF); + } + if (elapsed >= kLongPressMs) { + s_long_fired = true; + pagerLockingPopupHide(); + if (g_lv.task) g_lv.task->lockScreen(); + } + } else if (!held) { + pagerLockingPopupHide(); + } + s_was_held = held; +} + +// ---- Backspace hold-to-unlock (mirrors the T-Deck's trackball hold-to- +// unlock) -- only meaningful while hard-locked, so it never conflicts with +// updatePagerBackspaceHold()'s "back" gesture above (that one already exits +// early whenever the screen is off, and lockScreen() never sets _manual_lock +// without also turning the screen off, so the two are mutually exclusive). +// Must NOT early-return on isScreenOff() -- unlike every other pager input +// poller, this one has to keep working while the screen is dark, since that's +// exactly the state it's meant to end. +static void updatePagerBackspaceUnlockHold(unsigned long now) { + if (!g_lv.task || !g_lv.task->isManualLock()) return; + const bool held = pagerKeyboardBackspaceHeld(); + static constexpr uint32_t kLongPressMs = 1000; + static bool s_was_held = false; + static uint32_t s_press_start = 0; + + if (held && !s_was_held) { + s_press_start = now; + } else if (held && (now - s_press_start) >= kLongPressMs) { + g_lv.task->unlockScreen(); + s_was_held = false; // consume -- don't immediately re-trigger were this called again while still held + return; + } + s_was_held = held; +} + +// Keyboard backlight: off/on/auto (s_kb_bl_mode, shared with every CAP_KEYBOARD +// board) applied to the physical GPIO46 LEDC PWM. Unlike the T-Deck's slider, +// this board has no brightness curve to honour -- the backlight is a simple +// full-on/off strip under the keys, so "on" is just max PWM duty (255). Runs +// UNCONDITIONALLY, even while the screen is off/locked -- forcing it dark in +// that state is exactly its job here, so (unlike updatePagerBackspaceHold, +// which must NOT act while off) it can't be skipped the same way. +static void updatePagerKbBacklight(unsigned long now) { + uint8_t kb_bl = 0; + if (s_kb_bl_mode == 1) kb_bl = 255; + else if (s_kb_bl_mode == 2 && (now - s_kb_last_key_ms) < kKbBacklightIdleMs) kb_bl = 255; + if (g_lv.task && (g_lv.task->isScreenOff() || g_lv.task->isManualLock())) kb_bl = 0; + static uint8_t s_last = 0xFF; // only hit the LEDC write when the value actually changes + if (kb_bl != s_last) { s_last = kb_bl; pagerKeyboardSetBacklight(kb_bl); } +} +#endif + +#if defined(HAS_PAGER_ENCODER) +// T-LoRa Pager rotary encoder: a single linear nav axis (not 2D like the +// trackball, so none of updateTrackball()'s game/emoji-grid/cursor special +// cases apply) — each detent moves focus one step via the same navFifo the +// KEYPAD indev already drains (tanmatsuKeypadRead), and a click is ENTER +// (short) or ESC (long), exactly as specced. 1000 ms long-press threshold +// matches the existing MomentaryButton convention used elsewhere (PIN_USER_BTN). +static void updatePagerEncoder(unsigned long now) { + int delta = pagerEncoderReadDelta(); + const bool held = pagerEncoderClickHeld(); + + // No touch and no trackball on this board: the encoder (and the keyboard, + // see the HAS_PAGER_KEYBOARD drain in loop()) are the ONLY way to wake an + // idle-dimmed screen. Turning it just wakes -- it's swallowed here rather + // than also acting as nav, matching the trackball's edge-triggered + // wake+consume pattern (T-Deck/Tanmatsu always have touch or the trackball + // as a separate wake path; this board doesn't, so the gap is fatal there + // and had to be closed here instead of copied from either of them). + // Deliberately NOT waking on a bare click (held, no turn): BOOT already + // covers a dedicated wake button, and a click landed here right as the + // screen came on would fall straight into the click-release logic below + // as an ordinary short click on whatever was already focused -- reported + // bug: waking via the encoder button selected "Skip" on the setup + // wizard's welcome screen the instant the screen lit up. + if (g_lv.task && g_lv.task->isScreenOff()) { + // Hard-locked: a plain turn must NOT wake/unlock -- only holding Backspace + // does (updatePagerBackspaceUnlockHold). Without this gate any idle turn + // of the knob bypassed the lock entirely. + if (delta != 0 && !g_lv.task->isManualLock()) g_lv.task->wakeScreen(); + return; + } + // Screen already on: turning/clicking the encoder is real activity too, same as a + // keypress -- without this the idle timer kept counting down through continuous + // rotary navigation (nothing else on this board resets it; see handleHwKey()'s + // matching TLORA_PAGER fix) and the screen dimmed mid-use. + if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput(); + if (delta != 0 || held) noteKbActivity(); // same activity counts for the keyboard-backlight auto mode + + // Alt+turn is a modifier combo, not a solo Alt tap -- mark it used so a + // release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT. + if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed(); + + if (s_mentionnav_active) { + // @-mention contact picker (handleHwKey()'s Fn+Space entry / mentionNavConfirm()): + // captures the encoder exclusively while active, same priority as the accent + // picker and an open dropdown below (mention and accent never show at once — + // composerSuggestRefresh() -- so there's no ordering conflict between them). + const bool turned = (delta != 0); + if (s_mentionbox_cell_n > 0) { + for (; delta > 0; delta--) s_mentionnav_idx = (s_mentionnav_idx + 1) % (int)s_mentionbox_cell_n; + for (; delta < 0; delta++) s_mentionnav_idx = (s_mentionnav_idx - 1 + (int)s_mentionbox_cell_n) % (int)s_mentionbox_cell_n; + } + if (turned) mentionNavRestyle(); + } else if (s_accentnav_active) { + // Accent-variant picker (handleHwKey()'s Fn+Space entry / accentNavConfirm()): + // captures the encoder exclusively while active, same priority as an open + // dropdown below. + const bool turned = (delta != 0); + if (s_accbox_cell_n > 0) { + for (; delta > 0; delta--) s_accentnav_idx = (s_accentnav_idx + 1) % (int)s_accbox_cell_n; + for (; delta < 0; delta++) s_accentnav_idx = (s_accentnav_idx - 1 + (int)s_accbox_cell_n) % (int)s_accbox_cell_n; + } + if (turned) accentNavRestyle(); + } else if (navOpenDropdown()) { + // An open dropdown captures the encoder: lv_dropdown's own key handling only + // understands LV_KEY_UP/DOWN to move the highlighted row (+ENTER to confirm, + // already wired below via the short-click path) — it ignores LV_KEY_NEXT/PREV, + // which is what a plain turn sends in the else branch below. Without this + // capture, turning the encoder while a dropdown list is open fell through to + // NEXT/PREV, which the focus group instead consumes to move focus OFF the + // dropdown — so the highlight never moved and the list was stuck showing + // whatever was already selected (reported: opens fine, but turning doesn't + // scroll to a choice). Mirrors Tanmatsu's identical navOpenDropdown() capture + // in navPump() — see that comment for the lv_dropdown behavior this relies on. + for (; delta > 0; delta--) navPushTap(LV_KEY_DOWN); + for (; delta < 0; delta++) navPushTap(LV_KEY_UP); + } else if (pagerKeyboardAltHeld() && navOnMainPage()) { + // Alt (the bottom-left orange key, otherwise a hold-only modifier for the + // keyboard's symbol layer — free to reuse here since it types nothing on + // its own) + turn jumps directly between the 5 main tabs (Chats/Contacts/ + // Home/Map/Settings). The bottom tab bar is deliberately not a nav-group + // focus target (same as T-Deck/Tanmatsu), and unlike those boards the + // pager has no separate dedicated hotkeys to reach it, so plain turning + // could otherwise only ever move focus WITHIN the current screen — + // reported: the tab bar icons were unreachable from Home. Scoped to the + // main-tab level (navOnMainPage()) — reported bug: this used to fire even + // inside a settings sheet/chat, silently abandoning it to jump tabs. + for (; delta > 0; delta--) navSwitchTab(+1); + for (; delta < 0; delta++) navSwitchTab(-1); + } else if (pagerKeyboardAltHeld()) { + // Inside a nested scrollable screen (settings detail sheet, chat, modal): Alt+turn + // free-scrolls the page instead, like a touch drag. The keyboard-nav scroll-into-view + // path (navFocusCb's lv_obj_scroll_to_view with LV_ANIM_OFF) doesn't reliably repaint + // on this board — confirmed on hardware: focus and blur-to-save side effects reach + // fields far down a page, but the glass keeps showing the old scroll position. + // navScrollFocused()'s ANIMATED lv_obj_scroll_by should sidestep that: LVGL's anim + // timer re-invalidates every tick instead of relying on a single one-shot invalidate, + // the same reason touch-drag scrolling on the other boards never showed this bug. + // After the turn, snap focus to whatever's now nearest the top of the visible + // viewport so plain NEXT/PREV resumes from a sane, visible spot instead of a focus + // stuck off-glass. + const bool scrolled = (delta != 0); + lv_obj_t* container = nullptr; + for (; delta > 0; delta--) container = navScrollFocused(false); + for (; delta < 0; delta++) container = navScrollFocused(true); + if (scrolled) { + if (!container) container = navNearestScrollableAncestor(lv_group_get_focused(s_nav_group)); + if (container) navRefocusFirstVisible(container); + } + } else { + for (; delta > 0; delta--) navPushTap(LV_KEY_NEXT); + for (; delta < 0; delta++) navPushTap(LV_KEY_PREV); + } + + static constexpr uint32_t kLongPressMs = 1000; + static bool s_was_held = false; + static uint32_t s_press_start = 0; + static bool s_long_fired = false; + + if (held && !s_was_held) { + s_press_start = now; + s_long_fired = false; + } else if (held && !s_long_fired && (now - s_press_start) >= kLongPressMs) { + pagerNavGoBack(); // see pagerNavGoBack() above for the ladder + rationale + s_long_fired = true; + } else if (!held && s_was_held && !s_long_fired) { + // Released before the long-press threshold -> short click, same as a + // keyboard Enter: on a focused chat bubble that means the per-message + // action menu (navEnterBubble), not a plain ENTER keypress -- mirrors + // handleHwKey()'s Enter branch exactly so both inputs agree. + if (s_mentionnav_active) mentionNavConfirm(); // picking a mention: confirm the highlighted one + else if (s_accentnav_active) accentNavConfirm(); // picking an accent: confirm the highlighted one + else if (!navEnterBubble()) navPushTap(LV_KEY_ENTER); + } + s_was_held = held; +} +#endif + // Specific popups that float OVER the app drawer / a tab. Used to swallow swipe // gestures so they don't leak to the drawer or switch tabs underneath them. // Defined OUTSIDE HAS_TDECK_KEYBOARD so the ungated gesture handlers can use it. @@ -30918,10 +31777,12 @@ static bool anyPopupOpen() { return popupRegistryAny(); } static bool hwKeyDismissTopPopup() { return popupRegistryDismissTop(); } #endif // HAS_TDECK_KEYBOARD || HAS_TANMATSU (hwKeyDismissTopPopup) -#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) -// Keys that act as "close the popup" when no text field is focused. The user -// picked the easy-to-find corner keys; there's no dedicated Esc on this keyboard. +#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_PAGER_KEYBOARD) || defined(HAS_M9_KEYBOARD) +// Keys that act as "close the popup" when no text field is focused. static bool isDismissKey(int key) { +#if defined(HAS_TDECK_KEYBOARD) + // The user picked the easy-to-find corner keys; there's no dedicated Esc on + // this keyboard. switch (key) { case 0x08: // backspace case 0x0D: case 0x0A: // enter @@ -30931,6 +31792,14 @@ static bool isDismissKey(int key) { return true; default: return false; } +#else + // The pager's corner letters are real, constantly-typed QWERTY keys (unlike + // the T-Deck's sparser layout) -- treating them as "dismiss" would eat normal + // typing. Its dismiss key is the rotary encoder's long-press (-> LV_KEY_ESC + // via the nav FIFO, see updatePagerEncoder), not a keyboard key. + (void)key; + return false; +#endif } // drawerPopupOpen() / anyPopupOpen() are defined just above the @@ -30945,7 +31814,7 @@ static int tabForKey(int key) { (void)key; return -1; } -#endif // HAS_TDECK_KEYBOARD (keyboard helpers; the lock screen below is top-level) +#endif // HAS_TDECK_KEYBOARD || HAS_PAGER_KEYBOARD || HAS_M9_KEYBOARD (keyboard helpers; the lock screen below is top-level) #if CAP_LOCK_SCREEN // ---- Lock screen ------------------------------------------------------------- @@ -31171,6 +32040,12 @@ static void lockscreenShow() { lv_obj_t* hint = lv_label_create(s_lock_root); #if defined(HAS_TANMATSU) lv_label_set_text(hint, TR("press Volume Down to unlock")); +#elif defined(HAS_PAGER_KEYBOARD) + // Defensive only -- lockscreenShow() is never actually called on this board + // today (its callers are all HAS_TDECK_GT911-gated); the pager's own lock + // uses the plain off+wake path with no overlay, per updatePagerSpaceHold()/ + // updatePagerBackspaceUnlockHold(). Kept correct in case that changes. + lv_label_set_text(hint, TR("hold Backspace to unlock")); #elif defined(HAS_THINKNODE_M9) lv_label_set_text(hint, TR("hold the d-pad to unlock")); #else @@ -31209,9 +32084,7 @@ static void serviceLockscreen() { } #endif // core lock screen (HAS_TDECK_GT911 || HAS_TANMATSU) -#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // re-enter the keyboard block the lock screen was carved out of -#if defined(HAS_TDECK_GT911) // wallpaper picker is SD/SPIFFS-backed -> T-Deck only -// ---- Lock-screen wallpaper picker (lists JPEGs in internal /lock/ + SD) ---- +#if CAP_SOUND_FILES // custom WAV notification sounds -- T-Deck (SD/SPIFFS) or pager (SPIFFS only) // ---- Notification-sound chooser (Settings -> Sound) ------------------------ // Tapping a slot button opens a small menu: "Choose .wav from files" (opens the // File Manager, exactly like the wallpaper picker; opening a .wav there offers @@ -31234,7 +32107,7 @@ static void sndMenuBuiltinCb(lv_event_t* e) { if (s_snd_btn_lbl[slot] && lv_obj_is_valid(s_snd_btn_lbl[slot])) lv_label_set_text(s_snd_btn_lbl[slot], TR("Built-in")); sndMenuClose(); - tdeckPlayNotifySlot(slot); // preview the built-in chime + uiPlaySlot(slot); // preview the built-in chime } static void sndMenuFilesCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; @@ -31284,8 +32157,9 @@ static void openSoundPickerCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; openSoundMenu((int)(intptr_t)lv_event_get_user_data(e)); } -#endif // HAS_TDECK_GT911 (sound chooser — needs T-Deck's I2S amp) +#endif // CAP_SOUND_FILES +// ---- Lock-screen wallpaper picker (lists JPEGs in internal /lock/ + SD) ---- #if defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) // wallpaper picker — SD/SPIFFS-backed, board-agnostic static lv_obj_t* s_lockwall_picker = nullptr; static char s_lockwall_paths[24][TOUCH_LOCK_WALLPAPER_MAXLEN]; @@ -31449,10 +32323,6 @@ static void lockColorChosenCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(TR("Lock text colour set"), 1000); } #endif // HAS_TDECK_GT911 || HAS_THINKNODE_M9 (wallpaper picker + lock text colour) -// The settings-backup import picker below must link on BOTH touch boards (its -// Import button is not keyboard-gated), so briefly close the enclosing -// HAS_TDECK_KEYBOARD region here and reopen it right after openBackupPicker(). -#endif // HAS_TDECK_KEYBOARD — paused for the board-agnostic backup picker // ---- Settings backup: import file picker ------------------------------------ // Mirrors the lock-wallpaper picker UX: a full-screen list of *.json files on @@ -31959,7 +32829,12 @@ static void buildBackupsSettings() { // Reopen the HAS_TDECK_KEYBOARD region paused above for the backup picker; it // closes at that region's original #endif further below. (The next #if is the // original spacebar-countdown guard, now nested one level deeper — harmless.) -#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) +// Widened to HAS_PAGER_KEYBOARD too: handleHwKey() (defined in this region) +// is exactly the routing function PagerKeyboard's char stream needs, and +// every genuinely T-Deck-specific bit inside this range (the spacebar-lock +// countdown, HAS_TDECK_GT911 touch bits) is already independently re-gated +// on its own narrower macro, so it stays excluded for the pager regardless. +#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_PAGER_KEYBOARD) || defined(HAS_M9_KEYBOARD) #if defined(HAS_TDECK_KEYBOARD) || defined(HAS_M9_KEYBOARD) // ---- Spacebar lock countdown ------------------------------------------------- @@ -32147,6 +33022,18 @@ if (g_lv.task && g_lv.task->isManualLock()) { #else // Idle-dimmed (not hard-locked): ignore keys; a touch/click wakes into the UI. if (g_lv.task && g_lv.task->isScreenOff()) return; +#if defined(TLORA_PAGER) + // No touch and no trackball on this board: every OTHER input path that resets the + // idle timer (touch taps, trackball moves) doesn't exist here, and below this point + // a keypress only calls noteUserInput() when it lands in a focused field, dismisses + // a popup, or matches a tab hotkey -- tabForKey() no longer maps any letters (tab + // jumps moved to the trackball-only programmable hotkeys) and isDismissKey() always + // returns false on this full QWERTY, so with no field focused EVERY key silently + // no-op'd here and the idle timer kept counting down while the user was actively + // pressing keys (reported bug: screen dims after ~30s despite keyboard input). + if (g_lv.task) g_lv.task->noteUserInput(); + noteKbActivity(); // any key counts as activity for the keyboard-backlight auto mode too +#endif #endif // HAS_M9_KEYBOARD (wake-from-idle if/else) #if defined(HAS_TDECK_KEYBOARD) && defined(TDECK_KEYCODE_PROBE) // TEMP bring-up probe: toast the raw byte of every key so we can see what the @@ -32165,12 +33052,26 @@ if (g_lv.task && g_lv.task->isManualLock()) { // The on-screen keyboard is never shown on the T-Deck, but a textarea is still // bound to it on focus — that binding is our target. lv_obj_t* ta_focused = lv_keyboard_get_textarea(g_lv.keyboard); -#if CAP_KEYPAD_NAV +#if CAP_KEYPAD_NAV && !defined(TLORA_PAGER) // Edit mode (keyboard-nav ON only): a focused field becomes the typing target after // select/Enter (below) — until then `ta` is null so the nav keys navigate. With // keyboard-nav OFF there is no navigation to protect (and no nav group to set the edit // flag), so a focused field always types directly — otherwise typing breaks entirely. + // (Excludes the pager: it has no s_kbd_nav concept — see its own arm below.) lv_obj_t* ta = (ta_focused && (!s_kbd_nav || s_nav_ta_editing)) ? ta_focused : nullptr; +#elif defined(TLORA_PAGER) + // showKb() binds the composer to g_lv.keyboard as soon as a chat panel + // opens and leaves it bound even after nav focus moves to another widget + // in the same panel (e.g. the quick-reply/emoji icon) -- treating "a field + // is bound" as "we're editing a field" then routes Enter into the + // composer's submit/newline handling below instead of activating whatever + // is actually focused. Only count as editing when nav focus is really ON + // that field (mirrors CAP_TRACKBALL's s_kbd_nav/s_nav_ta_editing check + // above). Reported bug: the rotary encoder's short click opened the + // quick-reply/emoji picker (it bypasses this function entirely, sending + // LV_KEY_ENTER straight into the focus group via navPushTap()), but Enter + // on the keyboard did not. + lv_obj_t* ta = (ta_focused && s_nav_group && lv_group_get_focused(s_nav_group) == ta_focused) ? ta_focused : nullptr; #else lv_obj_t* ta = ta_focused; #endif @@ -32178,7 +33079,80 @@ if (g_lv.task && g_lv.task->isManualLock()) { if (m9HandleArrowKey(key, ta)) return; // ← runs BEFORE the if(!ta) split #endif if (!ta) { -#if CAP_KEYPAD_NAV +#if defined(TLORA_PAGER) + // No field is bound to the on-screen keyboard, so nav focus is on a plain + // widget (button/switch/list row). Enter = the same "submit/click" the + // encoder's short click also sends via navPushTap(LV_KEY_ENTER) -- + // works during the setup wizard too, matching encoder parity (hence + // ahead of the s_setup_root check below). EXCEPT on a focused chat + // bubble, which has no touch/trackball to long-press here -- Enter opens + // the same Ack/Mention/Copy/Info/Block action menu instead (navEnterBubble, + // shared with Tanmatsu's identical Enter-on-bubble handling, and with the + // encoder's own short click in updatePagerEncoder() -- both inputs agree). + if (key == 0x0D) { + if (!navEnterBubble()) navPushTap(LV_KEY_ENTER); + if (g_lv.task) g_lv.task->noteUserInput(); + return; + } + // Jump to latest: this board has no touch to tap the floating "scroll to + // bottom" circle (LvChatPanel::jump_btn) that appears once you've + // scrolled up in a chat, so a plain Backspace tap does the same jump + + // hide the T-Deck's own jumpToLatestCb() does on click (mirrors the + // Tanmatsu F6 hardware-key handler above, plus the hide step that one is + // missing). Only intercepted while the button is actually showing, so a + // Backspace tap at the bottom of the chat (or outside a chat) still falls + // through to its normal no-op / hold-to-back behavior below. Also moves + // nav focus to the composer -- without touch, nav focus was left sitting + // on whichever message bubble was focused pre-jump, so typing a reply + // right after catching up meant first navigating there manually. + if (key == 0x08) { + LvChatPanel* cp = navOpenChatPanel(); + if (cp && cp->msgs && cp->jump_btn && !lv_obj_has_flag(cp->jump_btn, LV_OBJ_FLAG_HIDDEN)) { + lv_obj_scroll_to_y(cp->msgs, LV_COORD_MAX, LV_ANIM_ON); + lv_obj_add_flag(cp->jump_btn, LV_OBJ_FLAG_HIDDEN); + if (cp->composer_ta && lv_obj_is_valid(cp->composer_ta)) { + lv_group_focus_obj(cp->composer_ta); + s_nav_show = true; + } + if (g_lv.task) g_lv.task->noteUserInput(); + return; + } + } + // Slider nudge: this board has no touch/trackball to drag a slider's + // knob, so a focused lv_slider (Control Center brightness, a Settings + // slider, the Map zoom bar, …) is otherwise stuck at whatever value it + // opened with. Q/E reuse navMoveDir()'s existing slider-capture branch + // (proportional ~20-presses-end-to-end step, live update + persist) — + // the same adjustment the T-Deck trackball's LEFT/RIGHT already does — + // rather than a fixed step that's wrong for every slider's range. (Was + // D/F; moved to Q/E to free up WASD for map panning below.) + if (key == 'q' || key == 'Q' || key == 'e' || key == 'E') { + lv_obj_t* focused = s_nav_group ? lv_group_get_focused(s_nav_group) : nullptr; + if (focused && lv_obj_check_type(focused, &lv_slider_class)) { + navMoveDir((key == 'e' || key == 'E') ? NAV_RIGHT : NAV_LEFT); + return; + } + } + // Map pan: WASD moves the map around on the Map tab (no touch/trackball + // to drag it), reusing the same mapNudge() step/re-render Tanmatsu's + // Ctrl+Arrow already uses. W=north A=west S=south D=east. + if ((key == 'w' || key == 'W' || key == 'a' || key == 'A' || + key == 's' || key == 'S' || key == 'd' || key == 'D') && + getActiveTab() == MAP_TAB_INDEX) { + switch (key) { + case 'w': case 'W': mapNudge(0); break; + case 's': case 'S': mapNudge(1); break; + case 'a': case 'A': mapNudge(2); break; + case 'd': case 'D': mapNudge(3); break; + } + return; + } +#endif +#if CAP_TRACKBALL || defined(HAS_THINKNODE_M9) + // A field is focused but we're in navigate mode: select/Enter starts editing it, so the + // letter-nav keys keep navigating until you explicitly enter the field (matches navPump). + // navFocusedTextarea() (nav-group focus), not the local ta_focused (kbd-bound field): the + // two can differ, and M9 needs the nav-group's own view of what's focused here. if (navFocusedTextarea() && s_kbd_nav && (key == '\r' || key == '\n' || navDirForKey(key) == 4)) { s_nav_ta_editing = true; if (g_lv.task) g_lv.task->noteUserInput(); @@ -32263,6 +33237,36 @@ if (g_lv.task && g_lv.task->isManualLock()) { return; } txtMenuHide(); // any keypress while editing dismisses an open edit menu +#if defined(TLORA_PAGER) + // Accent-variant / @-mention popups (issues #22, #42) are otherwise + // unreachable here: no touch, and their cells are NAV_SKIP_FLAG by design + // (touch boards pick them by tap). The rotary encoder walks the highlighted + // cell (updatePagerEncoder()); Enter (the encoder's own click) confirms; + // Backspace cancels without touching the text that triggered it. + // + // The @-mention list grabs the encoder the INSTANT it appears (set in + // mentionBoxMaybeShow()) -- it only shows up after the user has deliberately + // typed "@partial" looking for someone, so there's no ongoing-typing to + // protect. The accent box is different: it pops up after almost every + // vowel/consonant typed, so stealing focus immediately would swallow normal + // typing -- it stays passive until explicitly armed with Fn(Alt)+Space. + if (s_mentionnav_active) { + if (key == 0x08 || key == 0x7F) { mentionBoxHide(); return; } + if (key == 0x0D) { mentionNavConfirm(); return; } + return; // swallow everything else while picking (typing, etc.) + } + if (s_accentnav_active) { + if (key == 0x08 || key == 0x7F) { accentBoxHide(); return; } + if (key == 0x0D) { accentNavConfirm(); return; } + return; // swallow everything else while picking (typing, etc.) + } + if (key == ' ' && pagerKeyboardAltHeld() && s_accbox) { + s_accentnav_active = true; + s_accentnav_idx = 0; + accentNavRestyle(); + return; + } +#endif #if defined(HAS_M9_KEYBOARD) // M9 has a dedicated hardware Back key — unlike the CAP_TRACKBALL board's backspace-on- // empty-field escape below, this doesn't require emptying the field first: Back always @@ -32330,7 +33334,7 @@ if (g_lv.task && g_lv.task->isManualLock()) { // goes through hideKb(); this covers the physical-keyboard Enter. accentBoxHide(); mentionBoxHide(); -#if defined(HAS_TDECK_GT911) +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) if (s_editor_ta && ta == s_editor_ta) { lv_textarea_add_char(ta, '\n'); // multiline editor: Enter inserts a newline } else if (s_term_input_ta && s_kb_bind_ta == s_term_input_ta) { @@ -32366,7 +33370,7 @@ if (g_lv.task && g_lv.task->isManualLock()) { #endif } } else { -#if CAP_KEYPAD_NAV +#if CAP_KEYPAD_NAV && !defined(TLORA_PAGER) // Keyboard/d-pad nav: Enter in a generic field ADVANCES rather than submits — with // multiple fields on one page (e.g. the wizard's Wi-Fi step: SSID + password) there's // no single "the field" to submit from, so Enter walks the focus group forward one @@ -33070,7 +34074,21 @@ static void ccToggle(lv_obj_t* parent, const char* sym, const char* label, // ---- Display backlight brightness (PWM on PIN_TFT_LEDA_CTL, active-high) ---- // Both touch boards expose PIN_TFT_LEDA_CTL (T-Deck + Heltec V4 TFT), so the // brightness slider is available on both. -#if defined(PIN_TFT_LEDA_CTL) && (PIN_TFT_LEDA_CTL >= 0) +#if defined(TLORA_PAGER) +// The pager ALSO defines PIN_TFT_LEDA_CTL (=42, for naming consistency across +// boards — see platformio.ini), but that pin drives the AW9364's pulse-counted +// stepped dimmer, not a PWM-capable pin — the #if below would drive nonsense +// pulse timing into it via ledcAttachPin/ledcWrite. Must come before that +// check. ST7796LCDDisplay already owns the real AW9364 protocol. +#define HAS_CC_BRIGHTNESS 1 +static uint8_t s_brightness_pct = 100; +static void applyBrightness(uint8_t pct) { + if (pct < 5) pct = 5; + if (pct > 100) pct = 100; + s_brightness_pct = pct; + display.setBrightness(pct); +} +#elif defined(PIN_TFT_LEDA_CTL) && (PIN_TFT_LEDA_CTL >= 0) #define HAS_BACKLIGHT_PWM 1 #define HAS_CC_BRIGHTNESS 1 static uint8_t s_brightness_pct = 100; @@ -33263,6 +34281,14 @@ static void openControlCenter() { lv_obj_set_size(card, card_w, 384); // bigger: header + 3 roomier sliders + toggle grid + sysinfo #elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) lv_obj_set_size(card, card_w, 200); // sysinfo + thin brightness slider + 2-row toggle grid (fits 240−22 screen) +#elif defined(TLORA_PAGER) + // 222-px-tall screen: the shared V4/T-Deck-landscape 212px card below is + // tuned for a 240px-tall screen (212 + the card's own 4px y-offset = 216, + // fits under a 218px root there) -- on the pager's shorter 222px screen that + // same card overflows the 200px root by 16px, pushing the toggle row/sysinfo + // text off the bottom of the physical display. Size from the actual + // available height instead of the shared constant, with a small margin. + lv_obj_set_size(card, card_w, sh - STATUSBAR_H - 4 - 6); #else // Portrait has headroom on the 320-tall screen; make the card taller so the // brightness slider + toggles + sysinfo all get their own rows. @@ -33499,8 +34525,10 @@ static void openControlCenter() { lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_row(row, 4, LV_PART_MAIN); #else - // V4: up to 5 chips (Wi-Fi/BT/GPS/Theme/Sound) in one row, sized to fit width. - // WRAP as a safety net so they never overflow the (narrow, in portrait) card. + // V4/pager: up to 6 chips (Wi-Fi/BT/GPS/Theme/Keyboard/Sound) in one row, + // sized to fit width (chip width is computed from the actual count below, + // not hardcoded, since which chips appear varies per board). WRAP as a + // safety net so they never overflow the (narrow, in portrait) card. lv_obj_set_size(row, card_w - 20, 54); lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); lv_obj_set_style_pad_column(row, 5, LV_PART_MAIN); @@ -33510,8 +34538,8 @@ static void openControlCenter() { lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); const bool gps_on = g_lv.task && g_lv.task->getGPSState(); - // T-Deck: chips in a 2-row grid. V4: up to 5 chips sized to fit the card width - // with even gaps (5 chips + 4 gaps across the content width). + // T-Deck: chips in a 2-row grid. V4/pager: chips sized to fit the card width + // with even gaps, divisor derived from the actual chip count below. int tw = 66, th = 54; #if defined(HAS_TANMATSU) tw = (card_w - 20 - 20) / 3; // 3 chips per row (Wi-Fi/BT/GPS/Theme/Keys/Sound → 2×3 grid) @@ -33520,7 +34548,21 @@ static void openControlCenter() { #elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) tw = 58; th = 36; #else - tw = (card_w - 20 - 4 * 5) / 5; // 5 chips (Wi-Fi/BT/GPS/Theme/Sound) + 4 gaps + // Count only the chips this board/session will actually add below, so the + // width doesn't assume a fixed chip count -- which chips appear varies per + // board (V4 has no Keyboard chip; only HAS_UI_SOUND boards get a Sound + // chip), so a hardcoded divisor silently overflows this row and wraps + // extra chips onto a clipped 2nd line under the fixed 54px height. + int chip_count = 2; // Wi-Fi, Theme always shown + if (!g_lv.task || g_lv.task->hasBleCapability()) chip_count++; // BT + chip_count++; // GPS (toggle or info-only, always shown) +#if CAP_KEYBOARD + chip_count++; +#endif +#if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU) + chip_count++; +#endif + tw = (card_w - 20 - 5 * (chip_count - 1)) / chip_count; // 5px gap, matches row's pad_column above if (tw > 76) tw = 76; #endif // Each chip: tap = toggle, long-press = jump to that feature's settings page @@ -33816,7 +34858,7 @@ static void appTileCb(lv_event_t* e) { #if defined(HAS_TOUCH_UI) case APPACT_TERMINAL: homeTerminalCb(e); return; #endif -#if CAP_FILESYSTEM +#if CAP_FILESYSTEM || defined(TLORA_PAGER) case APPACT_FILES: homeFilesCb(e); return; #endif default: break; @@ -33849,6 +34891,7 @@ static void addAppTile(lv_obj_t* parent, int x, int y, int w, int h, lv_obj_set_style_radius(t, 12, LV_PART_MAIN); lv_obj_set_style_bg_color(t, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN | LV_STATE_PRESSED); lv_obj_set_style_bg_opa(t, LV_OPA_20, LV_PART_MAIN | LV_STATE_PRESSED); + lv_obj_add_flag(t, NAV_ACCENTFOCUS_FLAG); // keyboard/encoder focus = same accent tint as a touch press lv_obj_add_event_cb(t, appTileCb, LV_EVENT_CLICKED, (void*)(intptr_t)act); // Rounded-square chip (iOS-style squircle), tinted with the app's accent @@ -34165,7 +35208,7 @@ static void openAppDrawer() { #if defined(HAS_TOUCH_UI) { ">_", "Terminal", APPACT_TERMINAL, 0, 0x3DD27A }, // console green #endif -#if CAP_FILESYSTEM +#if CAP_FILESYSTEM || defined(TLORA_PAGER) { LV_SYMBOL_DIRECTORY, "Files", APPACT_FILES, 0, 0xE6BE4A }, // folder gold #endif { nullptr, "Snake", APPACT_SNAKE, 0, 0x53C06B }, // snake game (icon drawn from APPACT_SNAKE, not a glyph) @@ -35999,6 +37042,7 @@ static void setupWizardClose() { if (s_setup_root) { popupClose(&s_setup_root); } s_setup_name_ta = nullptr; s_setup_region_list = nullptr; + s_setup_region_next_btn = nullptr; s_setup_ssid_ta = nullptr; s_setup_pwd_ta = nullptr; if (g_statusbar.root) lv_obj_clear_flag(g_statusbar.root, LV_OBJ_FLAG_HIDDEN); @@ -36080,6 +37124,9 @@ static void setupFinishCb(lv_event_t* e) { static void setupRegionRowCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; const int idx = (int)(intptr_t)lv_event_get_user_data(e); +#if CAP_KEYPAD_NAV + const bool was_already_sel = (idx == s_setup_region_sel); +#endif s_setup_region_sel = idx; if (!s_setup_region_list) return; const uint32_t n = lv_obj_get_child_cnt(s_setup_region_list); @@ -36088,6 +37135,21 @@ static void setupRegionRowCb(lv_event_t* e) { if (c) lv_obj_set_style_bg_color( c, lv_color_hex((int)i == idx ? COLOR_STATUS_OK : 0x1A1B1C), LV_PART_MAIN); } +#if CAP_KEYPAD_NAV + // With 20 region presets in this list, walking NEXT one detent at a time past + // every remaining row just to reach Next/Back is impractical on a keypad/rotary + // -only board (reported: picking a region left no perceived way to advance). + // But auto-jumping to Next on the FIRST click (the original fix) moved focus + // off the list before the colour change was even visible to confirm which + // row got picked (reported: "I couldn't be certain I selected the correct + // one"). Two-step now: the first click on a row just selects/recolours it + // and focus stays put; only a SECOND click on the row that's ALREADY + // selected — an explicit confirm — jumps focus to Next. + if (was_already_sel && s_nav_group && s_setup_region_next_btn) { + lv_group_focus_obj(s_setup_region_next_btn); + s_nav_show = true; + } +#endif } static void setupFillRegionList() { @@ -36130,6 +37192,7 @@ static void setupShowStep(int step) { lv_obj_clean(s_setup_root); s_setup_name_ta = nullptr; s_setup_region_list = nullptr; + s_setup_region_next_btn = nullptr; s_setup_ssid_ta = nullptr; s_setup_pwd_ta = nullptr; s_setup_step = step; @@ -36183,7 +37246,7 @@ static void setupShowStep(int step) { if (s_setup_region_sel < 0) s_setup_region_sel = findMatchingMeshRadioPreset(the_mesh.getNodePrefs()); setupFillRegionList(); setupBtn("Back", setupBackCb, false, 12, btn_y, 72); - setupBtn("Next", setupRegionNextCb, true, sw - 12 - 120, btn_y, 120); + s_setup_region_next_btn = setupBtn("Next", setupRegionNextCb, true, sw - 12 - 120, btn_y, 120); } else { int y = setupHeader("Wi-Fi & Bluetooth", nullptr, "Step 3 of 3"); lv_obj_t* m = lv_label_create(s_setup_root); @@ -40207,6 +41270,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // default rotation is 270 -> we run the UI landscape (logical 800x480) via LVGL sw-rotate. s_ui_rotation = LV_DISP_ROT_270; #endif +#if defined(TLORA_PAGER) + // Panel is native 222x480 portrait; the pager has no touch to reorient with, + // so always run landscape via hardware MADCTL rotation (ROT_270 -> panel + // rotation 3, same mapping the boot wordmark already uses). + s_ui_rotation = LV_DISP_ROT_270; +#endif #if defined(HAS_THINKNODE_M9) // M9 is landscape in hardware: boot DISPLAY_ROTATION=1 verified upright on // the tester's unit (bring-up #6). Without this override the portrait @@ -40285,6 +41354,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // touch reports in this same 284x616 logical space. g_lv.disp_drv.hor_res = 284; g_lv.disp_drv.ver_res = 616; +#elif defined(TLORA_PAGER) + // Fixed landscape via hardware MADCTL rotation (like T-Deck/Heltec below), + // just a different native panel size — no ui_landscape ternary needed since + // this board is never portrait. + g_lv.disp_drv.hor_res = 480; + g_lv.disp_drv.ver_res = 222; #else // Landscape rotates the panel in HARDWARE (smooth — no per-pixel software // rotation each flush), so tell LVGL the already-rotated resolution and let @@ -40351,6 +41426,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // Trackball: set up the direction GPIOs/ISRs and orient motion to the UI. tdeckTrackballBegin(); tdeckTrackballSetRotation(s_ui_rotation); +#endif +#if defined(HAS_PAGER_KEYBOARD) + pagerKeyboardBegin(); +#endif +#if defined(HAS_PAGER_ENCODER) + pagerEncoderBegin(); #endif // (Audio: the I2S speaker amp is installed on demand per tone — see // tdeckPlayNotify — so nothing to set up at boot.) @@ -40360,18 +41441,23 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // nullptr, and creating a child of nullptr was the boot-loop cause. buildGlobalStatusBar(); -#if defined(HAS_TANMATSU) - // No touchscreen: drive the UI with a KEYPAD indev fed by badge-bsp keys, with - // a focus group navMaybeRebuild() repopulates per screen. +#if defined(HAS_TANMATSU) || defined(TLORA_PAGER) + // No touchscreen: drive the UI with a KEYPAD indev, with a focus group + // navMaybeRebuild() repopulates per screen. Tanmatsu feeds it from badge-bsp + // keys (navPump, below); the pager feeds it from its own keyboard/encoder + // drain in the main loop (mirrors the T-Deck's handleHwKey() path for + // typing, plus a small updatePagerEncoder() for focus movement — see loop()). s_nav_group = lv_group_create(); // populated per-screen by navMaybeRebuild()/navCollect() lv_group_set_focus_cb(s_nav_group, navFocusCb); // amber focus ring + scroll-into-view lv_indev_drv_init(&g_lv.indev_drv); g_lv.indev_drv.type = LV_INDEV_TYPE_KEYPAD; - g_lv.indev_drv.read_cb = tanmatsuKeypadRead; + g_lv.indev_drv.read_cb = tanmatsuKeypadRead; // device-neutral: just drains navFifo g_lv.indev_drv.disp = lv_disp_get_default(); if (lv_indev_t* kp = lv_indev_drv_register(&g_lv.indev_drv)) lv_indev_set_group(kp, s_nav_group); else pushDiagLine("LVGL keypad indev failed"); +#if defined(HAS_TANMATSU) bsp_input_get_queue(&s_nav_queue); +#endif #else // Physical touchscreen indev — skipped in remote mode (the panel is a placeholder; // its 320x240 coords would map wrong onto the 480x800 web UI). The web pointer drives it. @@ -41161,7 +42247,17 @@ static inline void touchPanelSleep(bool) {} * Panel RAM survives SLPIN, so wake is near-instant and the previous image is * still on the glass when the LED lights back up — no partial re-render. */ static inline void touchScreenBacklight(bool on) { -#if defined(HAS_BACKLIGHT_PWM) +#if defined(TLORA_PAGER) + // Also has TFT_BL defined (=-1, disabling TFT_eSPI's own backlight pin + // support), which would otherwise fall into the #elif defined(TFT_BL) branch + // below and digitalWrite a "-1" pin -- must come first. touchPanelSleep() + // has no ST7796-specific implementation (falls to the no-op default below, + // same as Tanmatsu), so screen-sleep here is backlight-off only, not a real + // panel sleep command -- a known, low-severity gap, not a regression (this + // board never had panel-sleep to begin with). + if (on) { touchPanelSleep(false); display.setBrightness(s_brightness_pct); } + else { display.setBrightness(0); touchPanelSleep(true); } +#elif defined(HAS_BACKLIGHT_PWM) // Both touch boards drive the backlight via LEDC PWM on PIN_TFT_LEDA_CTL once // applyBrightness() has claimed the pin at boot. A plain digitalWrite would // then be a no-op (on the V4, TFT_BL == PIN_TFT_LEDA_CTL == GPIO21), so drive @@ -41901,7 +42997,18 @@ void UITask::loop() { #else // Generic: Heltec V4 -- short press toggles screen + lock if (v == LOW && s_user_btn_prev == HIGH) { if (_screen_off) { +#if defined(TLORA_PAGER) + /* Pager only: hard-locked means BOOT is a no-op -- holding Backspace + * is the deliberate unlock gesture (updatePagerBackspaceUnlockHold). + * A plain idle-dimmed (not manually locked) screen still wakes on + * BOOT exactly as before. NOT applied to the V4 below (#else of this + * #if), which has no keyboard -- BOOT is its only lock/unlock control + * and must keep instantly unlocking it. */ + if (!_manual_lock) wakeScreen(); +#else + /* wakeScreen() clears _manual_lock so subsequent touches work. */ wakeScreen(); +#endif } else { touchScreenBacklight(false); setCpuForScreen(false); @@ -42081,7 +43188,7 @@ void UITask::loop() { else if (g_lv.ch.detail_open) jumpBtnsSetDim(&g_lv.ch, true); else s_jump_dimmed = true; // nothing open: park the state } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // microSD insert/remove detection — only while the file manager is open, so // there's no idle SPI traffic. SD.begin runs on this loop task (never // concurrent with the radio's SPI), and reuses the radio's already-begun bus. @@ -42146,6 +43253,8 @@ void UITask::loop() { } #if CAP_TRACKBALL updateTrackball(now); +#elif defined(HAS_PAGER_ENCODER) + updatePagerEncoder(now); #endif #if defined(HAS_TDECK_KEYBOARD) // Drain physical-keyboard presses buffered by the touch task into the field. @@ -42186,6 +43295,53 @@ void UITask::loop() { tdeckKeyboardSetBacklight(kb_bl); serviceLockscreen(); // refresh the lock-screen clock on minute roll-over serviceLockingCountdown(now); // advance / fire the spacebar "Locking…" countdown +#elif defined(HAS_PAGER_KEYBOARD) + // No separate core-0 touch task to own the I2C bus (no touch at all), so poll + // and drain right here, once per tick. Space press-and-hold locks the screen + // (updatePagerSpaceHold); Backspace press-and-hold unlocks it again + // (updatePagerBackspaceUnlockHold) -- the latter must run unconditionally, + // BEFORE the isScreenOff() split below, since it has to keep working while + // the screen is dark. updatePagerKbBacklight() is the same story -- it has + // to force the backlight dark the instant the screen goes off/locked, so it + // runs unconditionally too, rather than being skipped like the awake-only + // helpers in the else branch below. + pagerKeyboardPoll(); + updatePagerBackspaceUnlockHold(now); + updatePagerKbBacklight(now); + if (g_lv.task && g_lv.task->isScreenOff()) { + // Same rationale as updatePagerEncoder(): no touch/trackball wake path on + // this board, so a keypress while idle-dimmed just wakes the screen + // instead of being silently swallowed (which is what handleHwKey()'s own + // isScreenOff() guard does on every other board -- fine there since they + // always have touch or the trackball to wake with instead). Drain the + // whole batch so nothing queued here leaks through as real input on the + // very next tick right after waking. + bool any = false; + for (int kbi = 0; kbi < 12; ++kbi) { + if (pagerKeyboardReadKey() <= 0) break; + any = true; + } + // Discard any Alt tap / Alt+Shift / Alt+Backspace chord picked up while + // idle-dimmed -- none of them may fire (NEXT / Caps toggle / jump Home) + // the instant the screen wakes. + pagerKeyboardConsumeAltTap(); + pagerKeyboardConsumeAltShiftChord(); + pagerKeyboardConsumeAltBackspaceChord(); + // Hard-locked: an ordinary keypress must NOT wake/unlock -- only holding + // Backspace does (updatePagerBackspaceUnlockHold, already polled above). + if (any && !g_lv.task->isManualLock()) g_lv.task->wakeScreen(); + } else { + for (int kbi = 0; kbi < 12; ++kbi) { + int key = pagerKeyboardReadKey(); + if (key <= 0) break; + handleHwKey(key); + } + updatePagerAltTapNext(); + updatePagerAltShiftChord(); + updatePagerAltBackspaceChord(); + updatePagerBackspaceHold(now); + updatePagerSpaceHold(now); + } #elif defined(HAS_M9_KEYBOARD) // M9's keyboard controller is on its OWN bus (Wire1) — no touch task shares // it, so polling happens right here on the UI thread rather than from a @@ -42405,6 +43561,10 @@ void UITask::loop() { #if defined(HAS_TANMATSU) navMaybeRebuild(); // keep the keyboard-nav focus group in sync with the visible screen navPump(); // drain bsp keys: queue focus moves, type straight into focused fields +#elif defined(TLORA_PAGER) + // No touch fallback here either (like Tanmatsu) -- nav is always on, just fed + // from the keyboard/encoder drain above instead of a bsp queue. + navMaybeRebuild(); #elif CAP_KEYPAD_NAV // Keep the focus group synced whenever EITHER nav mode is active: the keyboard ESDFX nav // (T-Deck) / d-pad nav (M9) fed by handleHwKey, or the trackball D-pad nav (fed by @@ -42542,12 +43702,12 @@ static const PopupEnt k_popup_registry[] = { { P_OPEN(s_local_sensors_root), []{ closeLocalSensorsPage(); }, PF_COUNT }, // was in no registry at all #endif { P_OPEN(s_siginfo_root), []{ closeSigInfoPopup(); }, PF_COUNT }, -#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) +#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) { P_OPEN(s_fm_img_root), []{ fmImageClose(); }, PF_COUNT }, { P_OPEN(s_editor_root), []{ fmEditorClose(); }, PF_COUNT }, { P_OPEN(s_fm_prompt), []{ fmPromptClose(); }, PF_COUNT }, { P_OPEN(s_fm_actions), []{ fmCloseActions(); }, PF_COUNT }, -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) { P_OPEN(s_fm_snd_root), []{ fmSndClose(); }, PF_COUNT }, // was in no registry at all #endif { P_OPEN(s_fm_fmt_overlay), nullptr, PF_COUNT }, // format progress: block keys, not dismissable diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index a3a0b64..48e3a1e 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -35,6 +35,24 @@ #define CAP_OTA 1 // native dual-OTA slot #define CAP_LOCK_SCREEN 1 +#elif defined(TLORA_PAGER) // ===== LilyGo T-LoRa Pager (ESP32-S3) ===== + #define CAP_TOUCH 0 // no touchscreen — keyboard + rotary encoder nav only + #define CAP_ROTATABLE 0 // fixed 480x222 landscape via hardware MADCTL rotation + #define CAP_LARGE_SCREEN 0 // native 480x222, no UI upscaling + // CAP_SD/CAP_FILESYSTEM are 0 despite the hardware having a microSD slot: + // the code these caps gate (fmSdTryMount(), the #include block, the + // file manager's SD-vs-FFat backend selection) is still hardcoded to + // HAS_TDECK_GT911/HAS_TANMATSU specifically, never migrated to be CAP_SD- + // generic — turning these on here just hits "SD"/"CARD_NONE"/"fmSdTryMount" + // undeclared, not real SD support. A real mount needs pager-specific wiring + // (CS 21, its own shared-SPI helper), which is unscheduled follow-up work, + // not part of this milestone. + #define CAP_SD 0 + #define CAP_FILESYSTEM 0 + #define CAP_GPS 1 // u-blox MIA-M10Q + #define CAP_OTA 1 // dual-OTA partition layout, same shape as the T-Deck + #define CAP_LOCK_SCREEN 1 + #elif defined(HAS_TANMATSU) // ===== Tanmatsu (ESP32-P4) ===== #define CAP_TOUCH 0 // no touchscreen — keypad nav only #define CAP_ROTATABLE 0 // fixed (software ROT_270 portrait->landscape) @@ -112,21 +130,24 @@ #endif // ---- Derived input capabilities --------------------------------------------- -// Physical keyboard: T-Deck matrix OR Tanmatsu keypad OR ThinkNode M9 keyboard. -#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_TANMATSU) || defined(HAS_M9_KEYBOARD) +// Physical keyboard: T-Deck matrix, Tanmatsu keypad, the pager's TCA8418, or +// the ThinkNode M9 keyboard. +#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_TANMATSU) || defined(HAS_PAGER_KEYBOARD) || defined(HAS_M9_KEYBOARD) #define CAP_KEYBOARD 1 #else #define CAP_KEYBOARD 0 #endif -// Focus-group D-pad navigation (no pointer): Tanmatsu keypad OR T-Deck trackball -// OR the ThinkNode M9's d-pad. The underlying machinery (navFifo, navMoveDir, +// Focus-group D-pad navigation (no pointer): Tanmatsu keypad, T-Deck trackball, +// the pager (no touch at all — the rotary encoder is its only nav input, so +// like Tanmatsu this is always-on, not an optional toggle like the T-Deck's), +// or the ThinkNode M9's d-pad. The underlying machinery (navFifo, navMoveDir, // the focus group, the secondary KEYPAD indev) is generic — only the *pump* // that feeds it differs per board: Tanmatsu's navPump() reads bsp-input events; // T-Deck's WASDZ-letter nav and the M9's raw d-pad bytes are both fed straight // from handleHwKey() instead (see UITask.cpp's `#elif defined(HAS_M9_KEYBOARD)` // block, parallel to the T-Deck's `#if CAP_TRACKBALL` block). -#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL) || defined(HAS_THINKNODE_M9) +#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) #define CAP_KEYPAD_NAV 1 #else #define CAP_KEYPAD_NAV 0 @@ -187,6 +208,21 @@ #define CAP_COMPANION 0 #endif +// Per-event WAV notification sounds + the file-browsing sound picker. This is +// deliberately NOT the same thing as CAP_SD/CAP_FILESYSTEM: it only means +// "can browse and play WAV files for notifications." Both the T-Deck and the +// pager can now pick a WAV from a real SD card too (their sound pickers write +// an "sd:"-prefixed pref, UITask.cpp's wavOpen()/fmOpenAudio()) -- but the +// pager's CAP_SD/CAP_FILESYSTEM stay 0 (see the comment on those above): that +// SD support was added by widening the specific file-manager/WAV-picker call +// sites individually (`|| defined(TLORA_PAGER)`), not by flipping the macros, +// since CAP_SD also gates ~30 unrelated, still-pager-deferred features. +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) + #define CAP_SOUND_FILES 1 +#else + #define CAP_SOUND_FILES 0 +#endif + // ---- On-device web browser (the "Web" reader app) --------------------------- // The reader fetches pages over on-device HTTPS, and a TLS handshake needs ~30 KB of // free INTERNAL heap. Only the 8 MB-PSRAM boards (T-Deck, Tanmatsu, ThinkNode M9, RAK diff --git a/variants/lilygo_tlora_pager/CustomLR1121.h b/variants/lilygo_tlora_pager/CustomLR1121.h new file mode 100644 index 0000000..e4fdae7 --- /dev/null +++ b/variants/lilygo_tlora_pager/CustomLR1121.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Adapted from the core fork's CustomLR1110{,Wrapper}.h +// (src/helpers/radiolib/, MIT-licensed MeshCore code) — LR1121 and LR1110 are +// both LR11x0-family chips sharing the same RadioLib base class, so this is a +// mechanical type swap, not new logic. Lives in the variant dir rather than +// the core fork because, as of 2026-07-06, upstream meshcore-dev/MeshCore has +// no CustomLR1121* or lilygo_tlora_pager target to crib instead (see +// TLORA_PAGER_PORT.md's Decision ② caveat) — this can move into the fork at +// the next core-* tag once it's proven on hardware. +#pragma once + +#include +#include "MeshCore.h" + +class CustomLR1121 : public LR1121 { + bool _rx_boosted = false; + + public: + CustomLR1121(Module *mod) : LR1121(mod) { } + + size_t getPacketLength(bool update) override { + size_t len = LR1121::getPacketLength(update); + if (len == 0 && getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) { + // we've just received a corrupted packet + // this may have triggered a bug causing subsequent packets to be shifted + // call standby() to return radio to known-good state + // recvRaw will call startReceive() to restart rx + MESH_DEBUG_PRINTLN("LR1121: got header err, calling standby()"); + standby(); + } + return len; + } + + float getFreqMHz() const { return freqMHz; } + + int16_t setRxBoostedGainMode(bool en) { + _rx_boosted = en; + return LR1121::setRxBoostedGainMode(en); + } + + bool getRxBoostedGainMode() const { return _rx_boosted; } + + bool isReceiving() { + uint16_t irq = getIrqStatus(); + bool detected = ((irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID) || (irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED)); + return detected; + } + + uint8_t getSpreadingFactor() const { return spreadingFactor; } +}; diff --git a/variants/lilygo_tlora_pager/CustomLR1121Wrapper.h b/variants/lilygo_tlora_pager/CustomLR1121Wrapper.h new file mode 100644 index 0000000..85055db --- /dev/null +++ b/variants/lilygo_tlora_pager/CustomLR1121Wrapper.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Adapted from the core fork's CustomLR1110Wrapper.h — see CustomLR1121.h for +// why this lives in the variant dir instead of the core fork. +#pragma once + +#include "CustomLR1121.h" +// Angle brackets with the full core-lib subpath, NOT the core's own bare +// quoted "RadioLibWrappers.h" -- CustomLR1110Wrapper.h gets away with that +// because it lives in the same src/helpers/radiolib/ directory (quote-include +// searches the including file's own dir first); this file lives in the +// variant dir instead, so a bare quoted include can't find them. +#include +#include + +class CustomLR1121Wrapper : public RadioLibWrapper { +public: + CustomLR1121Wrapper(CustomLR1121& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + + void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + ((CustomLR1121 *)_radio)->setFrequency(freq); + ((CustomLR1121 *)_radio)->setSpreadingFactor(sf); + ((CustomLR1121 *)_radio)->setBandwidth(bw); + ((CustomLR1121 *)_radio)->setCodingRate(cr); + updatePreamble(sf); + } + + void doResetAGC() override { lr11x0ResetAGC((LR11x0 *)_radio, ((CustomLR1121 *)_radio)->getFreqMHz()); } + bool isReceivingPacket() override { + return ((CustomLR1121 *)_radio)->isReceiving(); + } + float getCurrentRSSI() override { + float rssi = -110; + ((CustomLR1121 *)_radio)->getRssiInst(&rssi); + return rssi; + } + + void onSendFinished() override { + RadioLibWrapper::onSendFinished(); + _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts + } + + float getLastRSSI() const override { return ((CustomLR1121 *)_radio)->getRSSI(); } + float getLastSNR() const override { return ((CustomLR1121 *)_radio)->getSNR(); } + + uint8_t getSpreadingFactor() const override { return ((CustomLR1121 *)_radio)->getSpreadingFactor(); } + + void setRxBoostedGainMode(bool en) override { + ((CustomLR1121 *)_radio)->setRxBoostedGainMode(en); + } + bool getRxBoostedGainMode() const override { + return ((CustomLR1121 *)_radio)->getRxBoostedGainMode(); + } +}; diff --git a/variants/lilygo_tlora_pager/Es8311Codec.cpp b/variants/lilygo_tlora_pager/Es8311Codec.cpp new file mode 100644 index 0000000..17edbda --- /dev/null +++ b/variants/lilygo_tlora_pager/Es8311Codec.cpp @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "Es8311Codec.h" + +namespace { +// ES8311 register addresses (datasheet names). +constexpr uint8_t REG_RESET = 0x00; +constexpr uint8_t REG_CLK01 = 0x01; +constexpr uint8_t REG_CLK02 = 0x02; +constexpr uint8_t REG_CLK03 = 0x03; +constexpr uint8_t REG_CLK04 = 0x04; +constexpr uint8_t REG_CLK05 = 0x05; +constexpr uint8_t REG_CLK06 = 0x06; +constexpr uint8_t REG_CLK07 = 0x07; +constexpr uint8_t REG_CLK08 = 0x08; +constexpr uint8_t REG_SDPIN = 0x09; // DAC serial port format/width +constexpr uint8_t REG_SDPOUT = 0x0A; // ADC serial port -- mirrors SDPIN's iface bits on this chip +constexpr uint8_t REG_SYS0B = 0x0B; +constexpr uint8_t REG_SYS0C = 0x0C; +constexpr uint8_t REG_SYS0D = 0x0D; +constexpr uint8_t REG_SYS0E = 0x0E; +constexpr uint8_t REG_SYS10 = 0x10; +constexpr uint8_t REG_SYS11 = 0x11; +constexpr uint8_t REG_SYS12 = 0x12; +constexpr uint8_t REG_SYS13 = 0x13; +constexpr uint8_t REG_SYS14 = 0x14; +constexpr uint8_t REG_ADC15 = 0x15; +constexpr uint8_t REG_ADC16 = 0x16; +constexpr uint8_t REG_ADC17 = 0x17; +constexpr uint8_t REG_ADC1B = 0x1B; +constexpr uint8_t REG_ADC1C = 0x1C; +constexpr uint8_t REG_DAC31 = 0x31; // mute +constexpr uint8_t REG_DAC32 = 0x32; // digital volume, 0x00=-95.5dB .. 0xFF=+32dB +constexpr uint8_t REG_DAC37 = 0x37; +constexpr uint8_t REG_GPIO44 = 0x44; +constexpr uint8_t REG_GP45 = 0x45; + +// Clock-divider coefficients for MCLK = 256 x sample rate (the ratio the +// caller's I2S driver is expected to configure), covering the sample rates +// wavParse() accepts plus the tone generator's fixed 16 kHz. This is the +// subset of Espressif's public coeff_div table (esp_codec_dev's es8311.c, +// see NOTICE) at the 256x ratio: at that ratio every divider field is +// constant across rates -- pre_div=pre_multi=adc_div=dac_div=1, fs_mode=0 +// ("single speed"), lrck=0x00FF, bclk_div=4, adc_osr=0x10 -- except dac_osr, +// which steps from 0x20 to 0x10 at the 16 kHz/22.05 kHz speed-mode boundary. +struct RateCoeff { uint32_t rate; uint8_t dac_osr; }; +constexpr RateCoeff kRateCoeffs[] = { + {8000, 0x20}, {11025, 0x20}, {12000, 0x20}, {16000, 0x20}, + {22050, 0x10}, {24000, 0x10}, {32000, 0x10}, {44100, 0x10}, {48000, 0x10}, +}; + +const RateCoeff* findRateCoeff(uint32_t rate) { + for (const auto& c : kRateCoeffs) if (c.rate == rate) return &c; + return nullptr; +} +} // namespace + +bool Es8311Codec::writeReg(uint8_t reg, uint8_t val) { + wire_->beginTransmission(addr_); + wire_->write(reg); + wire_->write(val); + return wire_->endTransmission() == 0; +} + +bool Es8311Codec::readReg(uint8_t reg, uint8_t* val) { + wire_->beginTransmission(addr_); + wire_->write(reg); + if (wire_->endTransmission(false) != 0) return false; + if (wire_->requestFrom((int)addr_, 1) != 1) return false; + *val = (uint8_t)wire_->read(); + return true; +} + +bool Es8311Codec::begin(TwoWire& wire, uint8_t addr) { + wire_ = &wire; + addr_ = addr; + opened_ = false; + + wire_->beginTransmission(addr_); + if (wire_->endTransmission() != 0) return false; // no ack at this address + + bool ok = true; + // I2C noise immunity -- written twice; the first write right after the + // amp/codec rail powers up occasionally doesn't take. + ok &= writeReg(REG_GPIO44, 0x08); + ok &= writeReg(REG_GPIO44, 0x08); + + ok &= writeReg(REG_CLK01, 0x30); + ok &= writeReg(REG_CLK02, 0x00); + ok &= writeReg(REG_CLK03, 0x10); + ok &= writeReg(REG_ADC16, 0x24); // mic-gain default; ADC/mic path otherwise unused on this board + ok &= writeReg(REG_CLK04, 0x10); + ok &= writeReg(REG_CLK05, 0x00); + ok &= writeReg(REG_SYS0B, 0x00); + ok &= writeReg(REG_SYS0C, 0x00); + ok &= writeReg(REG_SYS10, 0x1F); + ok &= writeReg(REG_SYS11, 0x7F); + + // Bring the chip out of reset in slave mode (bit6 clear) -- the ESP32-S3 + // drives BCLK/WS/MCLK as I2S master, the codec follows. + ok &= writeReg(REG_RESET, 0x80); + + // Clock source = external MCLK pin, not inverted. + ok &= writeReg(REG_CLK01, 0x3F); + + ok &= writeReg(REG_SYS13, 0x10); + ok &= writeReg(REG_ADC1B, 0x0A); + ok &= writeReg(REG_ADC1C, 0x6A); + ok &= writeReg(REG_GPIO44, 0x58); // internal reference signal (ADCL+DACR) + + opened_ = ok; + return ok; +} + +bool Es8311Codec::start(uint32_t sampleRate) { + if (!opened_) return false; + const RateCoeff* c = findRateCoeff(sampleRate); + if (!c) return false; + + bool ok = true; + ok &= writeReg(REG_RESET, 0x80); // slave mode, chip active (see begin()) + ok &= writeReg(REG_CLK01, 0x3F); // external MCLK, not inverted (see begin()) + + // Standard I2S, 16-bit -- fixed for every caller of this driver (the + // notification-sound path never varies bit depth), so this is a direct + // write rather than the read-modify-write the reference driver uses to + // support runtime format/width changes we don't need. + ok &= writeReg(REG_SDPIN, 0x0C); + ok &= writeReg(REG_SDPOUT, 0x0C); + + ok &= writeReg(REG_CLK02, 0x00); // pre_div=1, pre_multi=1 + ok &= writeReg(REG_CLK05, 0x00); // adc_div=1, dac_div=1 + ok &= writeReg(REG_CLK03, 0x10); // fs_mode=0 (single speed) | adc_osr=0x10 + ok &= writeReg(REG_CLK04, c->dac_osr); + ok &= writeReg(REG_CLK07, 0x00); // lrck_h + ok &= writeReg(REG_CLK08, 0xFF); // lrck_l + ok &= writeReg(REG_CLK06, 0x03); // bclk_div=4 -> (4-1) + + // Power up the DAC path only -- ADC/mic stays powered down. + ok &= writeReg(REG_ADC17, 0xBF); + ok &= writeReg(REG_SYS0E, 0x02); + ok &= writeReg(REG_SYS12, 0x00); + ok &= writeReg(REG_SYS14, 0x1A); + ok &= writeReg(REG_SYS0D, 0x01); + ok &= writeReg(REG_ADC15, 0x40); + ok &= writeReg(REG_DAC37, 0x08); + ok &= writeReg(REG_GP45, 0x00); + + return ok; +} + +void Es8311Codec::suspend() { + if (!opened_) return; + writeReg(REG_DAC32, 0x00); + writeReg(REG_ADC17, 0x00); + writeReg(REG_SYS0E, 0xFF); + writeReg(REG_SYS12, 0x02); + writeReg(REG_SYS14, 0x00); + writeReg(REG_SYS0D, 0xFA); + writeReg(REG_ADC15, 0x00); + writeReg(REG_CLK02, 0x10); + writeReg(REG_RESET, 0x00); + writeReg(REG_RESET, 0x1F); + writeReg(REG_CLK01, 0x30); + writeReg(REG_CLK01, 0x00); + writeReg(REG_GP45, 0x00); + writeReg(REG_SYS0D, 0xFC); + writeReg(REG_CLK02, 0x00); +} + +void Es8311Codec::setMute(bool mute) { + if (!opened_) return; + uint8_t regv = 0; + readReg(REG_DAC31, ®v); + regv &= 0x9F; + writeReg(REG_DAC31, mute ? (regv | 0x60) : regv); +} + +void Es8311Codec::setVolumePercent(uint8_t pct) { + if (!opened_) return; + if (pct > 100) pct = 100; + if (pct == 0) { writeReg(REG_DAC32, 0x00); return; } + constexpr uint8_t kFloorReg = 0x60; // quietest setting that's still clearly audible + uint8_t reg = (uint8_t)(kFloorReg + ((uint32_t)(0xFF - kFloorReg) * pct) / 100); + writeReg(REG_DAC32, reg); +} diff --git a/variants/lilygo_tlora_pager/Es8311Codec.h b/variants/lilygo_tlora_pager/Es8311Codec.h new file mode 100644 index 0000000..753f8fe --- /dev/null +++ b/variants/lilygo_tlora_pager/Es8311Codec.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Minimal ES8311 codec driver (I2C register control only -- I2S data is +// configured/driven separately via the ESP32's own I2S peripheral, same as +// this repo's existing T-Deck sound code). Output (DAC/speaker) path only: +// the pager wires the codec to a one-way Class-D amp (NS4150B), so the +// ADC/mic side of the chip is left in its power-down reset default. +// +// Register sequence and clock-divider coefficients are derived from +// Espressif's public esp_codec_dev ES8311 driver (Apache-2.0) -- see +// NOTICE. This is original code, not a port of that driver's generic +// multi-codec plugin architecture: it's trimmed to exactly what this board +// needs -- DAC-only, ESP32-S3 as I2S master with the codec as slave, and an +// external MCLK fixed at 256x the sample rate (the caller's I2S driver must +// be configured that way before calling start()). +#pragma once + +#include +#include + +class Es8311Codec { +public: + // Probe the codec at `addr` on `wire` and push its startup register + // sequence (clocks left in a powered-down state -- call start() before + // playback). Returns false if the I2C probe or any register write fails. + bool begin(TwoWire& wire, uint8_t addr = 0x18); + + // Configure the I2S format/sample-rate dividers and power up the DAC + // path. `sampleRate` must be one of the rates in the codec's supported + // set (matches wavParse()'s accepted WAV sample rates, plus the fixed + // tone-generator rate); returns false and changes nothing on an + // unsupported rate. Call once per playback session, after the ESP32's + // own I2S driver is already clocking (the codec's PLL won't lock until + // MCLK is toggling). + bool start(uint32_t sampleRate); + + // Power down the DAC path. Call after playback finishes, before the + // amp's AMP_EN is dropped. + void suspend(); + + void setMute(bool mute); + + // Map the existing 0-100 UI volume pref onto the codec's digital-volume + // register range. The bottom of that range is inaudible on this amp, so + // nonzero settings are floored into an audible band -- same idea as + // Tanmatsu's applyVolume() (UITask.cpp), different codec/registers. + void setVolumePercent(uint8_t pct); + +private: + bool writeReg(uint8_t reg, uint8_t val); + bool readReg(uint8_t reg, uint8_t* val); + + TwoWire* wire_ = nullptr; + uint8_t addr_ = 0x18; + bool opened_ = false; +}; diff --git a/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp b/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp new file mode 100644 index 0000000..70c3bce --- /dev/null +++ b/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include "TLoraPagerBoard.h" + +void TLoraPagerBoard::begin() { + + ESP32Board::begin(); + + // XL9555/BQ27220 sit on the shared I2C bus (SDA 3 / SCL 2). ESP32Board::begin() + // only calls the pin-less Wire.begin() unless PIN_BOARD_SDA/SCL are defined + // for this env, so re-init explicitly with this board's pins before probing + // either chip. + Wire.begin(SDA, SCL); + + // Configure user button + pinMode(PIN_USER_BTN, INPUT); + + // Park every shared-SPI select/reset OUTPUT-HIGH before ANY bus traffic + // (display.begin() runs right after board.begin() in main.cpp, and the + // radio's NSS/RESET stay unclaimed until radio.begin() well after that). + // A floating select can leave that chip half-listening on the live bus; + // LilyGoLib parks this same set before its display init (initShareSPIPins()). + const uint8_t spi_selects[] = { P_LORA_NSS, P_LORA_RESET, PAGER_PIN_SD_CS, PAGER_PIN_NFC_CS }; + for (uint8_t pin : spi_selects) { + pinMode(pin, OUTPUT); + digitalWrite(pin, HIGH); + } + + const bool expander_ok = io_expander.begin(Wire, PAGER_XL9555_ADDR); + // Not just a nicety: every power rail AND the display's hardware reset hang + // off this chip — if the probe fails silently, the symptom downstream is a + // black screen with an otherwise clean boot log. + Serial.printf("[BOOT] xl9555 %s\n", expander_ok ? "ok" : "PROBE FAILED"); + if (expander_ok) { + // Drive EVERY channel the vendor drives (LilyGoLib LilyGo_LoRa_Pager.cpp + // begin()), not just the rails this app uses: DRV2605 (haptics) and the + // ES8311/amp are on the shared I2C bus and the ST25R3916 (NFC) is on the + // shared SPI bus — an UNPOWERED chip's pads can clamp a shared bus through + // its ESD diodes, so "off because unused" isn't safe here. DISP_RST first, + // same as the vendor list, so the panel's reset line is driven (not + // floating) as early as possible. + const uint8_t rails[] = { + PAGER_EXPAND_DISP_RST, + PAGER_EXPAND_KB_RST, + PAGER_EXPAND_LORA_EN, + PAGER_EXPAND_GPS_EN, + PAGER_EXPAND_DRV_EN, + PAGER_EXPAND_AMP_EN, + PAGER_EXPAND_NFC_EN, + PAGER_EXPAND_GPS_RST, + PAGER_EXPAND_KB_EN, + PAGER_EXPAND_GPIO_EN, + PAGER_EXPAND_SD_EN, + }; + for (uint8_t ch : rails) { + io_expander.pinMode(ch, OUTPUT); + io_expander.digitalWrite(ch, HIGH); + delay(1); // stagger rail turn-on, mirrors the vendor's bring-up order + } + + io_expander.pinMode(PAGER_EXPAND_SD_DET, INPUT); + io_expander.pinMode(PAGER_EXPAND_SD_PULLEN, INPUT); + + // Hardware-reset the ST7796 panel (XL9555 ch6 — see TLoraPagerBoard.h for + // why this line was invisible to the first bring-up pass). Same pulse shape + // as the vendor's: LOW 50ms, back HIGH. display.begin()'s own software + // reset + its mandated 120ms wait then run against a freshly-reset panel. + io_expander.digitalWrite(PAGER_EXPAND_DISP_RST, LOW); + delay(50); + io_expander.digitalWrite(PAGER_EXPAND_DISP_RST, HIGH); + + delay(50); // let rails + panel settle before anything downstream probes them + } + + // ES8311 audio codec presence check (address only -- no register writes + // yet, that's the sound code's job on first use). Every power rail this + // chip depends on is driven above; a missing ack here means the sound + // path won't work and is worth knowing from the boot log alone. + Wire.beginTransmission(0x18); + Serial.printf("[BOOT] es8311 %s\n", Wire.endTransmission() == 0 ? "ok" : "PROBE FAILED"); + + gauge.begin(Wire); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; // received a LoRa packet (while in deep sleep) + } + + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} diff --git a/variants/lilygo_tlora_pager/TLoraPagerBoard.h b/variants/lilygo_tlora_pager/TLoraPagerBoard.h new file mode 100644 index 0000000..c877746 --- /dev/null +++ b/variants/lilygo_tlora_pager/TLoraPagerBoard.h @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Board class for the LilyGo T-LoRa Pager (ESP32-S3). Unlike the T-Deck/ +// Heltec V4, power-rail control and battery reporting go through I2C chips — +// an XL9555 GPIO expander gates LoRa/GPS/keyboard/SD power, and a BQ27220 +// fuel gauge replaces the usual VBAT ADC divider (this board doesn't have +// one). begin() brings up just enough of that I2C chain for the rest of the +// app to run. +#pragma once + +#include +#include +#include +#include +#include +#include + +// XL9555 expander channels, matching LilyGoLib's LilyGo_LoRa_Pager.cpp begin() +// (the vendor's own bring-up — ground truth for this board). Channel 6 is the +// ST7796 panel's HARDWARE RESET, not a power rail: it's absent from both the +// LilyGoLib hardware doc's channel table and the canonical arduino-esp32 +// pins_arduino.h on master, which is why the first bring-up pass concluded +// "TFT_RST isn't wired" (TFT_RST=-1) and left it floating — producing +// intermittent, cold-boot-worse black screens with a perfectly clean boot log +// (a panel held in hardware reset ignores all SPI, including TFT_eSPI's +// software-reset fallback). +#define PAGER_EXPAND_DRV_EN 0 +#define PAGER_EXPAND_AMP_EN 1 +#define PAGER_EXPAND_KB_RST 2 +#define PAGER_EXPAND_LORA_EN 3 +#define PAGER_EXPAND_GPS_EN 4 +#define PAGER_EXPAND_NFC_EN 5 +#define PAGER_EXPAND_DISP_RST 6 +#define PAGER_EXPAND_GPS_RST 7 +#define PAGER_EXPAND_KB_EN 8 +#define PAGER_EXPAND_GPIO_EN 9 +#define PAGER_EXPAND_SD_DET 10 +#define PAGER_EXPAND_SD_PULLEN 11 +#define PAGER_EXPAND_SD_EN 12 + +#define PAGER_XL9555_ADDR 0x20 + +// Shared-SPI chip selects/resets that are NOT otherwise claimed before first +// bus traffic. The display's CS is TFT_eSPI's; the radio's NSS/RESET belong to +// RadioLib — but only from radio.begin() onward, which runs AFTER the display +// has already been painting. Until every one of these is parked OUTPUT-HIGH, +// whichever chip's line floats low can sit half-selected on the live bus +// (LilyGoLib parks this exact set before its display init — initShareSPIPins()). +#define PAGER_PIN_SD_CS 21 +#define PAGER_PIN_NFC_CS 39 + +// Gauge probe/refresh failed — never let the UI divide by zero. +#define PAGER_BATT_MILLIVOLTS_FALLBACK 3700 + +class TLoraPagerBoard : public ESP32Board { +public: + void begin(); + + void enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + + // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep + rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); + + rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); + + if (pin_wake_btn < 0) { + esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet + } else { + esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn + } + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000); + } + + // Finally set ESP32 into sleep + esp_deep_sleep_start(); // CPU halts here and never returns! + } + + uint16_t getBattMilliVolts() { + if (!gauge.refresh()) return PAGER_BATT_MILLIVOLTS_FALLBACK; + uint16_t mv = gauge.getVoltage(); + return mv > 0 ? mv : PAGER_BATT_MILLIVOLTS_FALLBACK; + } + + const char* getManufacturerName() const{ + return "LilyGo T-LoRa Pager"; + } + + // Mute/unmute the NS4150B amp via its XL9555 enable pin. Separate from the + // boot-time rail bring-up in begin() (which drives this HIGH permanently, + // for bus-integrity reasons unrelated to audio -- see begin()'s comment): + // this is the runtime toggle the sound code brackets each chime/WAV with, + // so the amp is only live while something is actually playing. + void setAmpEnabled(bool on) { + io_expander.digitalWrite(PAGER_EXPAND_AMP_EN, on ? HIGH : LOW); + } + + // TODO: BQ25896 charger (XPowersLib) bring-up is out of scope for now — the + // BQ27220 gauge alone covers battery %/mV for the UI. Add charge-status/ + // current reporting once the charger is wired in. + + ExtensionIOXL9555 io_expander; + GaugeBQ27220 gauge; +}; diff --git a/variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv b/variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv new file mode 100644 index 0000000..8657223 --- /dev/null +++ b/variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv @@ -0,0 +1,35 @@ +# LilyGo T-LoRa Pager touch firmware — custom partition table. +# +# 16 MB layout with TWO EQUAL OTA app slots so the firmware can self-update +# (HTTP OTA via the meshcomod proxy). Same shape as the T-Deck's touch +# partition table (see variants/lilygo_tdeck/partitions_tdeck_touch.csv) — no +# reason to diverge, same 16 MB flash. The app region (app0+app1) is split +# evenly at 3.875 MB each — the firmware is ~2.55 MB, leaving ~1.3 MB of growth +# headroom per slot. The tiles + spiffs offsets match the T-Deck's, so an +# OTA — or a one-time USB re-flash to install this table — keeps the map tile +# cache and all user data (prefs / contacts / channels / chat history) intact. +# - app0 3.875 MB (ota_0) ┐ equal A/B slots; OTA ping-pongs between them +# - app1 3.875 MB (ota_1) ┘ +# - "tiles" 4.75 MB LittleFS partition (subtype 0x83) for the map tab's +# slippy-tile pack +# - spiffs 3.375 MB for user data (/new_prefs, /contacts3, /channels2, chat +# history) +# +# Layout (16 MB total): +# 0x000000–0x009000 bootloader + partition table +# 0x009000–0x00E000 nvs (20 KB) +# 0x00E000–0x010000 otadata (8 KB) +# 0x010000–0x3F0000 app0 (3.875 MB, ota_0) +# 0x3F0000–0x7D0000 app1 (3.875 MB, ota_1) +# 0x7D0000–0xC90000 tiles (4.75 MB, LittleFS, type 0x83) [unchanged] +# 0xC90000–0xFF0000 spiffs (3.375 MB) [unchanged] +# 0xFF0000–0x1000000 coredump (64 KB) + +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x3E0000, +app1, app, ota_1, 0x3F0000, 0x3E0000, +tiles, data, 0x83, 0x7D0000, 0x4C0000, +spiffs, data, spiffs, 0xC90000, 0x360000, +coredump, data, coredump, 0xFF0000, 0x10000, diff --git a/variants/lilygo_tlora_pager/target.cpp b/variants/lilygo_tlora_pager/target.cpp new file mode 100644 index 0000000..99b2009 --- /dev/null +++ b/variants/lilygo_tlora_pager/target.cpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include "target.h" + +TLoraPagerBoard board; + +// The radio and display share the SAME physical SCLK/MISO/MOSI pins (only CS +// differs: LORA_NSS vs TFT_CS) -- genuine ESP32 bus sharing needs ONE SPIClass +// object serializing access via its own begin/endTransaction + each device's +// own CS, not two separate SPIClass hosts pointed at the same pins. Two hosts +// (confirmed on hardware) means whichever calls spi.begin() LAST silently +// steals the GPIO matrix's output routing for those pins away from the other +// -- the display's writes keep "succeeding" in software (no error, isOn() +// still true) but the electrical signal never reaches the glass again, since +// radio_init() (which must run after display.begin() -- see main.cpp) was +// that "last" caller. TFT_eSPI::getSPIinstance() is its public accessor for +// the exact SPIClass object display.begin() already attached to these pins; +// reusing it here (instead of our own separate SPIClass) is what makes this +// a real shared bus. Depends on DISPLAY_CLASS always being defined for this +// board today -- revisit if a headless pager env is ever added. +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, TFT_eSPI::getSPIinstance()); + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +ClockFloorRTC rtc_clock(fallback_clock); +MicroNMEALocationProvider gps(Serial1, &rtc_clock); +EnvironmentSensorManager sensors(gps); + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + fallback_clock.begin(); + + // rtc_clock.begin(Wire) is intentionally NOT called. This board has a real + // PCF85063A at I2C addr 0x51, but AutoDiscoverRTCClock (helpers/AutoDiscoverRTCClock.cpp) + // only knows the register-incompatible PCF8563 at that same address — its + // probe is just an I2C ACK check, so it would "detect" the PCF85063A as a + // PCF8563 and read its registers with the wrong layout, silently returning + // garbage timestamps instead of falling back cleanly. Skipping this just + // means we fall back to the ESP32 software clock, same as T-Deck/Heltec V4 + // today. Proper PCF85063A support (SensorLib's SensorPCF85063) is a + // follow-up — see TLORA_PAGER_PORT.md. + // + // I2C itself is already up: TLoraPagerBoard::begin() calls Wire.begin(SDA, SCL) + // and runs before radio_init() in main.cpp's setup(), so no Wire.begin() here. + + // No spi.begin() here: the shared SPIClass (see the `radio` global above) + // was already attached to SCLK/MISO/MOSI by display.begin(), which runs + // earlier in main.cpp's setup(). Calling begin() again here would just be + // this same object re-doing its own attach -- harmless in isolation, but + // unnecessary, and it's one less thing to keep in sync if the boot order + // ever changes. + +#if defined(USE_SX1262) + // SX1262 variant of this board (Milestone 10 — same NSS/RESET/BUSY/DIO1 + // pins as the LR1121 branch below; same PCB slot, different chip + // populated). CustomSX1262::std_init() is the exact same generic init the + // T-Deck already uses — reused as-is rather than hand-rolling a second + // manual sequence, since it already does everything the LR1121 branch + // below does by hand (begin() with freq/bw/sf/cr/power, setCRC(1), + // RF-switch config) driven entirely by this env's build flags (see + // platformio.ini's SX126X_* comments for why each one is/isn't set). + // nullptr here, not the shared SPIClass: the Module above was already + // constructed with it, so std_init()'s own spi->begin() would just be a + // harmless-but-unnecessary re-attach — same reasoning as the "No spi.begin() + // here" comment above. + return radio.std_init(nullptr); +#else +#ifdef LORA_CR + uint8_t cr = LORA_CR; +#else + uint8_t cr = 5; +#endif + + // LR1121 has no std_init() helper (unlike CustomSX1262) — the core fork's + // CustomLR1110 doesn't have one either, so this sequence is spelled out here + // instead of inside the wrapper. The 8-arg begin() already applies + // tcxoVoltage internally (RadioLib's LR11x0::modSetup() -> setTCXO()), so + // unlike trail-mate's initLoRa() — which calls the zero-arg begin() (default + // tcxoVoltage 1.6V) and therefore NEEDS a follow-up setTCXO(3.0f) fixup — + // passing 3.0f directly here makes a second setTCXO() call redundant. + int state = radio.begin(LORA_FREQ, LORA_BW, LORA_SF, cr, + RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, + LORA_TX_POWER, 8, 3.0f); + if (state != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(state); + return false; + } + + // RF-switch table on DIO5/DIO6 — verified against both trail-mate's + // initLoRa() and the RADIOLIB_LR11X0_DIO5/DIO6 + LR11x0::OpMode_t + // definitions in the pinned RadioLib (7.6.0) source. A wrong table here + // silently kills TX power without any error return (see the T-Deck's + // SX126X_DIO2_AS_RF_SWITCH war story in platformio.ini for that failure shape). + static const uint32_t rfswitch_dio_pins[] = { + RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11X0_DIO6, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, + }; + static const Module::RfSwitchMode_t rfswitch_table[] = { + { LR11x0::MODE_STBY, { LOW, LOW } }, + { LR11x0::MODE_RX, { LOW, HIGH } }, + { LR11x0::MODE_TX, { HIGH, LOW } }, + { LR11x0::MODE_TX_HP, { HIGH, LOW } }, + { LR11x0::MODE_TX_HF, { LOW, LOW } }, + { LR11x0::MODE_GNSS, { LOW, LOW } }, + { LR11x0::MODE_WIFI, { LOW, LOW } }, + END_OF_MODE_TABLE, + }; + radio.setRfSwitchTable(rfswitch_dio_pins, rfswitch_table); + + // LR11x0::begin() defaults to a 2-byte CRC; MeshCore's wire protocol expects + // 1 byte for interop with the SX126x/SX127x nodes already on the mesh + // (CustomSX1262::std_init() makes the same override for the same reason). + radio.setCRC(1); + + return true; +#endif +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/lilygo_tlora_pager/target.h b/variants/lilygo_tlora_pager/target.h new file mode 100644 index 0000000..1031722 --- /dev/null +++ b/variants/lilygo_tlora_pager/target.h @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#if defined(USE_SX1262) + // Milestone 10 (SX1262-variant env): CustomSX1262/CustomSX1262Wrapper are + // generic core-lib classes (angle include), unlike CustomLR1121 below -- + // T-Deck/Heltec V4's target.h include the exact same header. + #include +#else + #include "CustomLR1121Wrapper.h" +#endif +#include "TLoraPagerBoard.h" +#include +#include "../../src/helpers/ClockFloorRTC.h" // monotonic send-timestamp floor (issue #89) +#include +#ifdef DISPLAY_CLASS + #include + #include +#endif +#include "helpers/sensors/EnvironmentSensorManager.h" +#include "helpers/sensors/MicroNMEALocationProvider.h" + +extern TLoraPagerBoard board; +extern WRAPPER_CLASS radio_driver; +extern RADIO_CLASS radio; +extern ClockFloorRTC rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/lilygo_tlora_pager_pins/pins_arduino.h b/variants/lilygo_tlora_pager_pins/pins_arduino.h new file mode 100644 index 0000000..cda8613 --- /dev/null +++ b/variants/lilygo_tlora_pager_pins/pins_arduino.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Lives in its own folder, separate from variants/lilygo_tlora_pager/ (our +// board glue: TLoraPagerBoard.*, target.*, CustomLR1121*), because PlatformIO's +// arduino-esp32 build script unconditionally compiles every source file found +// under board_build.variants_dir// as a standalone "framework +// variant" library (platformio-build.py's corelib_env.BuildSources call) -- +// a build context with none of our own lib_deps include paths. Since this +// board has no framework-bundled variant, we must point variants_dir at our +// own repo; keeping this pin map alone in board.variant's folder means that +// auto-compile step finds nothing but a header (a no-op), while our .cpp +// files stay reachable only through our own build_src_filter, exactly once. +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +// USB descriptor — matches boards/lilygo-t-lora-pager.json's hwids (0x303A/0x82D4). +#define USB_VID 0x303A +#define USB_PID 0x82D4 +#define USB_MANUFACTURER "LilyGo" +#define USB_PRODUCT "wadamesh T-LoRa Pager" + +static const uint8_t LED_BUILTIN = 255; // no onboard status LED +#define BUILTIN_LED LED_BUILTIN +#define LED_BUILTIN LED_BUILTIN + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +// I2C bus: XL9555 expander, BQ25896 PMU, BQ27220 gauge, PCF85063 RTC, BHI260AP +// IMU, DRV2605 haptics, ES8311 codec, TCA8418 keyboard all share this bus. +static const uint8_t SDA = 3; +static const uint8_t SCL = 2; + +// Shared SPI bus: ST7796 display, LR1121/SX1262 radio, and microSD all share +// this bus (like the T-Deck's 40/41/38) — CS discipline + SPI transactions +// keep them from fighting each other. SS defaults to the microSD CS. +static const uint8_t SS = 21; +static const uint8_t MOSI = 34; +static const uint8_t MISO = 33; +static const uint8_t SCK = 35; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */