Previously kept local-only (see f18929b/cca9a44), moving pager tracker docs under gitignored dev-docs/. This one now ships in the repo instead, per explicit request -- keep it current alongside pager code changes. Signed-off-by: Tesso M Costa <tesso.martins@gmail.com>
74 KiB
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'sLR1120::checkOutputPower()(LR1121 inherits it: HP PA auto-selects above 14dBm, valid range -9..22 below 1GHz) and trail-mate's ownTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22for 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'spagerEncoderReadDelta()(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)(innavFocusCb) 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-shotLV_ANIM_OFFscroll doesn't cover. Root cause NOT fixed yet — an attempted fix (forcelv_obj_invalidate()on the scrolled ancestor afterscroll_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 vianavScrollFocused()'s ANIMATEDlv_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-drivenscroll_to_viewon a tall settings page is still broken; picking that apart further (why does one-shotLV_ANIM_OFFfail to invalidate on THIS board specifically?) is unscheduled follow-up — likely worth checking whetherlv_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:
- 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 plainLV_KEY_ENTERa bubble doesn't react to.updatePagerEncoder()'s short-click branch now callsnavEnterBubble()first, mirroringhandleHwKey()exactly. - 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.cppno longer decides the effect (it has no UI visibility) — it only reports the chord viapagerKeyboardConsumeAltShiftChord();UITask.cpp's newupdatePagerAltShiftChord()toggles Caps Lock ONLY while a text field is actually being edited (same "ta" derivationhandleHwKey()'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. - @-mention contact picker made encoder-navigable (commit
57802eb) — the same unreachable-without-touch problem the accent box had, fixed the same way (mentionNavRestyle()/mentionNavConfirm()mirroraccentNavRestyle()/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. - App-drawer tiles' keyboard/encoder focus highlight normalized to their
own accent tint (commit
6073b6f) — tiles previously fell through tonavFocusCb'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-tintedLV_STATE_PRESSEDlook they already show on a touch press. NewNAV_ACCENTFOCUS_FLAG(LV_OBJ_FLAG_USER_3) letsnavFocusCbspecial-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_FILESYSTEMlooked right from the hardware table (the pager does have a microSD slot), but the actual mount code (fmSdTryMount(),#include <SD.h>) is still hardcoded toHAS_TDECK_GT911specifically —device_caps.h's ownCAP_SDflag was never actually wired to it. Turning it on for the pager just producedSD/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
#elifscoping 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. Returnsfalsefor 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/PopfeedsLV_KEY_UP/DOWN/LEFT/RIGHT/ NEXT/PREV/ENTER/ESCintotanmatsuKeypadRead(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_editingflag), 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_ENCODERneeded — encoder ticks map tonavFifoPush(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,
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
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.csvis just the default — we override with our own OTA+tiles+spiffs csv viaboard_build.partitions(see worklist ①).variant: lilygo_tlora_pager+variants_dir: variants→ needsvariants/lilygo_tlora_pager/pins_arduino.h(write our own; don't copy trail-mate's — it brandsUSB_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 inplatformio.ini). Verify the device-profile frame over USB companion early (worklist ⑦); if it drops bytes, switch to TinyUSB CDC like the V4.
Decisions (architecture)
-
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.ininext to the existing two envs and intorelease.sh's env list later. -
LR1121 wrappers vendored in the variant dir — zero core-fork churn for bring-up. The core fork (
core-v1.16.5) only hasCustomLR1110*. Re-verified 2026-07-06: upstreammeshcore-dev/MeshCore'smainbranch does NOT currently haveCustomLR1121*or avariants/lilygo_tlora_pager/dir (checkedsrc/helpers/radiolib/— onlyCustomLR1110{,Wrapper}.hexists there too; issue meshcore-dev/MeshCore#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 authorCustomLR1121{,Wrapper}.hourselves by adapting the core fork's ownCustomLR1110{,Wrapper}.hpair (same RadioLibLR11x0family — swap the base type fromLR1110toLR1121), cross-checked against trail-mate'sinitLoRa()(which drives RadioLib's stockLR1121class directly, no custom wrapper) for the RF-switch table /setTCXOsequence. SinceCustomLR1121{,Wrapper}.honly subclass RadioLib'sLR1121and the core'sRadioLibWrapper(both on the include path), they can live invariants/lilygo_tlora_pager/for now and move into the fork at the nextcore-*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 fromCustomLR1110Wrapper.h's shape) don't resolve — those work in the core fork only because the file sits in the samesrc/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<helpers/radiolib/...>includes, matchingtarget.h's already-correct pattern for the same headers. -
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. -
Display = new
ST7796LCDDisplayapp-side (insrc/helpers/ui/), implementing the core'sDisplayDriverinterface (begin/width/height/startFrame/endFrame/ setDisplayRotation/writePixelsRGB565) — that's all the LVGL flush path uses. TFT_eSPI hasST7796_DRIVER(USER_SETUP_LOADED+-Dpin 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/-Dflags are vestigial (nothing else in the repo#includesTFT_eSPI.h). This makesST7796LCDDisplaythe first real TFT_eSPI consumer in this codebase — every method call was verified directly against the pinnedbodmer/TFT_eSPI @ ^2.5.43source, 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=1is set — missing from this decision's original flag list; a#errorguard inST7796LCDDisplay.cppnow 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 inST7796LCDDisplayitself usinglewisxhe/SensorLib'sAW9364LedDriverdirectly (a maintained library, already an M1-established dependency) rather than a hand-rolled driver. -
Input = the Tanmatsu registration branch (KEYPAD indev +
s_nav_grouponly, no pointer indev), gated by a new device cap. Rotary → nav FIFO; TCA8418 →handleHwKey(). New pollable drivers insrc/helpers/input/following the existing style (begin/poll/read API, no LVGL inside the driver). -
Battery/power via libraries, not hand-rolled (CONTRIBUTING rule): lewisxhe SensorLib covers BQ27220 (
GaugeBQ27220), XL9555 (ExtensionIOXL9555), PCF85063, DRV2605; XPowersLib covers the BQ25896.TLoraPagerBoard : public ESP32BoardoverridesgetBattMilliVolts()(gauge query),getManufacturerName(), power-rail bring-up inbegin()(expander), and sleep. -
One codebase: all pager-specific UI behavior rides existing/new
CAP_*flags insrc/ui-touch/device_caps.h— no forked screens. -
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 underboard_build.variants_dir/<board.variant>/as a separateFrameworkArduinoVariantlibrary, in a build context with none of our app'slib_depsinclude 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 ownvariants/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 forTLoraPagerBoard.*/target.*/CustomLR1121*— so the framework tried to compile those too, and failed on missingWire.h/RadioLib.h. Fixed by movingpins_arduino.halone into a newvariants/lilygo_tlora_pager_pins/folder and pointing the board JSON's"variant"there instead; our ownTLoraPagerBoard.*/target.*/CustomLR1121*stay invariants/lilygo_tlora_pager/, reached only via our ownbuild_src_filter, exactly once. Confirmed safe againstget_partition_table_csv()'s similarvariants_dir-based fallback logic (moot here sinceboard_build.partitionsis set to a full explicit path, which that function returns verbatim once its variant-relative lookup attempts fail). -
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 newdriver/i2s_std.hAPI where this codebase's existing T-Deck sound code uses the legacydriver/i2s.h(which already supports MCLK viai2s_pin_config_t.mck_io_num, so no new API family was needed). Wrotevariants/lilygo_tlora_pager/Es8311Codec.{h,cpp}instead — ~200 lines, DAC-only, register sequence and MCLK clock-divider coefficients derived from Espressif's publices8311.c/es8311_reg.h(seeNOTICE), fixed to a 256x-sample-rate MCLK ratio covering the standard 8k-48k rateswavParse()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. -
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 owncodec.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)
- ⓪ Research + board JSON verification; land
boards/lilygo-t-lora-pager.json+ this tracker. - ① 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 (mirrorsTDeckBoard.cpp).getBattMilliVolts()reads the gauge (refresh()+getVoltage()), falling back to 3700 mV if the probe/refresh fails.getManufacturerName()→ "LilyGo T-LoRa Pager".enterDeepSleep()mirrorsTDeckBoard.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). - ② Radio:
variants/lilygo_tlora_pager/{CustomLR1121.h, CustomLR1121Wrapper.h, target.h, target.cpp}.CustomLR1121{,Wrapper}.hauthored by adapting the core fork's ownCustomLR1110{,Wrapper}.h(LR1110/LR1121 share RadioLib'sLR11x0base — confirmed by reading RadioLib 7.6.0 source directly: same protectedfreqMHz/spreadingFactormembers, samegetIrqStatus()/getRssiInst()inherited fromLRxxxx/LR11x0), since upstream has nothing to crib (see Decision ②).radio_init()intarget.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:- No explicit
radio.reset()beforebegin()— RadioLib'sLR11x0::modSetup()→findChip()already resets the chip internally (with retries); trail-mate's explicit reset is redundant, not wrong. - No second
radio.setTCXO(3.0f)call afterbegin()— trail-mate only needs that because itsinitLoRa()calls the zero-argbegin()(defaulttcxoVoltage=1.6V) and fixes it up after. Ourbegin()passes3.0fas the 8th arg directly, whichLR11x0::modSetup()already applies internally — a second call would be a no-op. - Added
radio.setCRC(1)afterbegin()(LR11x0'sbegin()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 callingrtc_clock.begin(Wire)inradio_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 pinnedjgromes/RadioLib @ ^7.6.0source (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 rawGPS_RX/GPS_TXmacro values, on purpose),GPS_BAUD_RATE=38400. Keep the standardRADIOLIB_EXCLUDE_*set (LR11X0 stays IN; can also exclude SX126X here).
- No explicit
- ③ 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/-Dflags are vestigial (nothing#includesTFT_eSPI.hanywhere else in the repo). Only theDisplayDriver-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 pinnedbodmer/TFT_eSPI @ ^2.5.43source 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 ownST7796_Rotation.halready has this exact fix (colstart=49/rowstart=49depending on rotation, applied automatically inside everysetAddrWindow()call) — but only whenCGRAM_OFFSETis#defined. UnlikeST7789_Defines.h(which self-defines it),ST7796_Defines.hdoes 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#errorguard in the new.cppthat 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 inST7796LCDDisplay(no separateAw9364Backlight.{h,cpp}—ST7789LCDDisplayturned out to have no brightness hook to mirror at all; brightness on the other two boards is aUITask.cpp-owned free function doing raw LEDC PWM onPIN_TFT_LEDA_CTL, which the AW9364's pulse protocol can't use). Consumeslewisxhe/SensorLib'sAW9364LedDriverdirectly (header-only, already vendored via the M1-established SensorLib dependency) rather than hand-rolling the pulse timing the milestone doc originally suggested. ExposessetBrightness(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 ofUITask.cpp's existingPIN_TFT_LEDA_CTLPWM 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).NOTICEupdated: added aSensorLibentry (missing since M1) and corrected theTFT_eSPI/Adafruit GFXlines' backend descriptions (both were wrong about which boards use which library).DISPLAY_CLASS=ST7796LCDDisplay. Gate: both shipping envs build unchanged (neither'sbuild_src_filterreferenceshelpers/ui/*.cppyet, so the new TU isn't even parsed by either compiler today) — full compiler verification of the new code waits for M4. - ④ Env:
[env:tlora_pager_lr1121_companion_radio_touch]inplatformio.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) — droppedadafruit/Adafruit ST7735 and ST7789 Libraryfrom the cloned list (T-Deck/Heltec's Adafruit display backend; unused here, TFT_eSPI replaces it). NoXPowersLib(M1 left the BQ25896 charger out of scope).default_envsleft unchanged (still just the two shipping boards). Also fixed two structural bugs found only by actually compiling (see Decision ⑧) and added one forced, minimalUITask.cpp#elif defined(TLORA_PAGER)arm (display-class typedef only — see the Status line). Compile gate: all three envs build green — verified. - ⑤ Input drivers:
src/helpers/input/{PagerKeyboard,PagerEncoder}.{h,cpp}. GatedHAS_PAGER_KEYBOARD/HAS_PAGER_ENCODER, already reached by every env's+<helpers/input/*.cpp>filter — noplatformio.inichange 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, soTDeckKeyboard.cppnever 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 inPagerKeyboard.cppitself. Reused trail-mate'sLilyGoKeyboardkeymap 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.kCapsPosrenamedkShiftPosto match. Chord effect changed again 2026-07-14:PagerKeyboard.cppno longer decides what Alt+Shift does (it has no UI visibility) — it only reports the chord viapagerKeyboardConsumeAltShiftChord();UITask.cpp'supdatePagerAltShiftChord()toggles persistent Caps Lock ONLY while a text field is actually being edited (same "ta" derivation ashandleHwKey()'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. SeePagerKeyboard.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 mirrorsTDeckKeyboard.cpp's (single poll context, UI-thread-safereadKey()) even though the underlying hardware access differs completely. Implemented as straightforward polling (available()/getEvent()drained everypagerKeyboardPoll()call), not INT-pin-gated, despite the milestone doc's "prefer INT-driven drain" suggestion — trail-mate's own INT handling does extraINT_STAT/GPIO_INT_STATregister 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 likeTDeckTrackball.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 andnoInterrupts()-snapshot read pattern asTDeckTrackball.cpp, just the right decode logic for a genuine quadrature signal. Divides byPAGER_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. - ⑥ UITask wiring:
device_caps.hgot itsTLORA_PAGERcap block (no touch, no rotate, no large-screen, GPS 1, OTA 1, lock-screen 1;CAP_SD/CAP_FILESYSTEMcorrected to 0 — see below) plus widenedCAP_KEYBOARD/CAP_KEYPAD_NAVderivations to recognizeHAS_PAGER_KEYBOARD/TLORA_PAGER.UITask.cppchanges, all pager-gated: KEYPAD indev registered by widening the Tanmatsu branch's#iftodefined(HAS_TANMATSU) || defined(TLORA_PAGER)(~35706) — pager reusestanmatsuKeypadRead's plumbing but notbsp_input_get_queue, which stays Tanmatsu-only; forceds_ui_rotation = LV_DISP_ROT_270and a newhor_res=480/ver_res=222branch (~35626); a newupdatePagerEncoder()(delta →navPushTap(NEXT/PREV), click → ENTER, long-press ≥1000ms → ESC); main-loop drain wired for bothpagerKeyboardPoll()/pagerKeyboardReadKey()→handleHwKey()and the encoder function above;applyBrightness()/touchScreenBacklight()got pager-first branches callingdisplay.setBrightness()instead of falling into thePIN_TFT_LEDA_CTLPWM branch (closes risk 1f — PWM would never have worked on the AW9364's discrete-pulse interface). Draw buffer already sized offhor_resso 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_FILESYSTEMlooked right from the hardware (real microSD slot) but the actual mount code is hardcoded toHAS_TDECK_GT911, not trulyCAP_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.
- ⑦ 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 inST7796LCDDisplay::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.shENVS, flasher manifest (deploy/flasher/manifest-tlora-pager.json), OTA env name viaFIRMWARE_OTA_ENV. Separate PR. - ⑩ (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; seeTLORA_PAGER_PORT_MILESTONES.md's Milestone 10 for the full writeup, including thetarget.hmissing-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
- 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
CustomLR1121crib source exists yet (re-verified 2026-07-06 — see Decision ②'s caveat). Milestone ② needs to author the wrapper by adapting the core fork'sCustomLR1110{,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. **GPSPIN_GPS_RX/PIN_GPS_TXare named from the GPS module's perspective in wadamesh's own core (EnvironmentSensorManager.cppcallsSerial1.setPins(PIN_GPS_TX, PIN_GPS_RX), andHardwareSerial::setPins()takes(rxPin, txPin)— soPIN_GPS_TXsupplies the ESP's own RX pin,PIN_GPS_RXsupplies the ESP's own TX pin). trail-mate's/the canonical arduino-esp32GPS_RX=4/GPS_TX=12macros are named the OPPOSITE way — its ownSerial1.begin(baud, cfg, GPS_RX, GPS_TX)call usesHardwareSerial::begin()'s(rxPin, txPin)order directly, so thereGPS_RXIS the ESP's own RX pin. Net result: wadamesh'sPIN_GPS_RXmust be set to12andPIN_GPS_TXto4for M4 — the raw trail-mate values swapped, not copied verbatim. Verified by reading both projects' actualSerial1.setPins()/begin()call sites and the ESP32 core'sHardwareSerial::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 same0x51address but has a different register layout (RTClib'sRTC_PCF8563driver would misread its registers), so callingrtc_clock.begin(Wire)would silently produce garbage timestamps instead of a clean fallback.target.cpp'sradio_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'sSensorPCF85063) is unscheduled follow-up work, not part of any milestone ①–⑩ yet. 1e.-D CGRAM_OFFSET=1must land in Milestone ④'s pager env flags. This panel's 222px glass is narrower than the ST7796 controller's 320px GRAM; TFT_eSPI'sST7796_Rotation.honly applies the required 49px column/row offset when this flag is set (unlikeST7789_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.cpphas a#errorguard that will catch the omission the moment M4 compiles this file — but the actual fix belongs inplatformio.ini, and it's easy to miss since the build "succeeds." 1f. M6 must add a pager branch toUITask.cpp's brightness code ahead of its existingPIN_TFT_LEDA_CTLPWM branch. Milestone ③ reused that macro name for the pager's AW9364 enable pin (naming consistency across boards), butUITask.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 definesPIN_TFT_LEDA_CTL=42for the pager, that existing branch will compile and run for it too unless M6 adds a#if defined(TLORA_PAGER)branch first that callsdisplay.setBrightness(pct)instead (the hookST7796LCDDisplayalready exposes for exactly this). 1g. RX-boosted-gain silently doesn't apply for the pager — RESOLVED 2026-07-07 (commitb3c6388). Was deferred, not fixed, in Milestone ④ (matches M3's shared-file-edit precedent): with-D USE_LR1121=1defined (notUSE_SX1262/USE_SX1268), three call sites defaulted/applied this radio setting only for the SX126x macros even thoughCustomLR1121Wrapperalready implementssetRxBoostedGainMode/getRxBoostedGainMode:src/MyMesh.cpp~2540 (default pref value on first boot), ~2556 and ~3355 (actually callingradio_driver.setRxBoostedGainMode(...)), andsrc/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 inCommonCLI.cpp'sradio.rxgaincompanion CLI command, checkingUSE_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, addingUSE_LR1121there too would be safe follow-up, just unscheduled.) - 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 inPagerEncoder.cpp'spagerEncoderReadDelta()(the driver, not UI code) so every consumer inherits the corrected sense from one place. 1h.CAP_SD/CAP_FILESYSTEMare 0 for the pager despite real microSD hardware — a latent gap indevice_caps.h's own abstraction, not a pager bug. File manager browsing + WAV notification-sound picker RESOLVED 2026-07-10 (commits8c7fb6a/1514e76), scoped deliberately:CAP_SDturned 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. InsteadCAP_SD/CAP_FILESYSTEMstay 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. AddedpagerSdCardPresent()(XL9555PAGER_EXPAND_SD_DET, confirmed-inverted polarity) and a pagersdSharedSPI()(reusesTFT_eSPI::getSPIinstance(), the same bus the radio already shares). Format support is explicitly out of scope (nof_mkfs/sd_diskio.hpulled 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), theMAX_UI_MESSAGES_SDchat-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, notHAS_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. Returnsfalsefor the pager instead — its Esc equivalent is the rotary encoder's long-press, handled separately inupdatePagerEncoder(). - 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).
- 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. - 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). - 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_TRANSACTIONSon 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'sinitShareSPIPins()equivalent). - NVS-preserving flash chain applies here too — 4-component flash, never the merged image (wipes saved Wi-Fi creds).
- 222-px vertical layout not yet audited against real constants —
STATUSBAR_H/TABBAR_H/CHAT_KB_Hand 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. - 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 newupdatePagerKbBacklight()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: neitherhandleHwKey()norupdatePagerEncoder()callednoteKbActivity()(the keyboard-backlight idle timer), only the separatenoteUserInput()(screen idle timer) — both now stamp it. Hardware-verified. - TX power default — RESOLVED 2026-07-07 (commit
b3c6388). Was conservatively set toLORA_TX_POWER=10with 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 toLORA_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 pinnedLR1120::checkOutputPower()source (LR1121 inherits it: the HP PA auto-selects above 14dBm, valid range -9..22) and cross-checked against trail-mate's own workingTRAIL_MATE_LORA_TX_POWER_MAX_DBM=22for this exact board. Caveat: a device already flashed before this fix keeps its savedtx_power_dbm=10preference — 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. - 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'slv_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) andlv_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-shotLV_ANIM_OFFcall 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 extralv_obj_invalidate()on the scrolled ancestor right afterscroll_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 vianavScrollFocused()'s animatedlv_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. - 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 toLV_EVENT_DEFOCUSEDon every textarea inbuildRadioSettings()(freq/bw/sf/cr/tx/airtime/region) and unconditionally calledthe_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 theui:lvglcheckpoint even though it isn't a rendering cost): 6 escalating stalls per navigation pass, up to 8.7s each. Fix: skipsetRadioParams()/region persistence on a silent (DEFOCUSED) blur when the parsed field values already matchNodePrefs, 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". - 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 (itsboard_facts.hdoesn't even carry anled_presentfield), and freshly-fetched vendor sources (LilyGoLib'sdocs/hardware/lilygo-t-lora-pager.mdhardware doc and its actualLilyGo_LoRa_Pager.cpp/.hfirmware — 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 pulsingKB_BACKLIGHT(GPIO46, wired up this session viaupdatePagerKbBacklight()), 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 havevariants/lilygo_tlora_pager/orCustomLR1121*(re-verified 2026-07-06, see Decision ②); onlyCustomLR1110{,Wrapper}.hexists insrc/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-esp32variants/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— 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 insrc/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 againstST7789_Defines.handsetAddrWindow()inTFT_eSPI.cpp— pulled via the pinnedbodmer/TFT_eSPI @ ^2.5.43lib_dep. - AW9364 backlight driver actually consumed (not hand-rolled):
lewisxhe/SensorLib'sAW9364LedDriver.hpp(MIT, header-only) — reference copy at~/dev/trail-mate/.pio/libdeps/*/SensorLib/src/AW9364LedDriver.hpp.