From be1996ce776e9af379956fb3abce73885642d573 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 6 Jul 2026 13:43:24 -0600 Subject: [PATCH 01/48] pager: land T-LoRa Pager variant skeleton (board JSON + board class) Board class, pins, and partition table for the LilyGo T-LoRa Pager (ESP32-S3, LR1121 radio variant), modeled on the T-Deck's shape. TLoraPagerBoard brings up the XL9555 power-rail expander and BQ27220 fuel gauge over I2C (this board has no VBAT ADC divider). Nothing references these files yet, so the shipping T-Deck/Heltec V4 envs are unaffected; radio/display/env wiring land in follow-up commits. Signed-off-by: Tesso M Costa --- boards/lilygo-t-lora-pager.json | 41 ++++++++++ .../lilygo_tlora_pager/TLoraPagerBoard.cpp | 51 +++++++++++++ variants/lilygo_tlora_pager/TLoraPagerBoard.h | 76 +++++++++++++++++++ .../partitions_tlora_pager_touch.csv | 35 +++++++++ variants/lilygo_tlora_pager/pins_arduino.h | 69 +++++++++++++++++ 5 files changed, 272 insertions(+) create mode 100644 boards/lilygo-t-lora-pager.json create mode 100644 variants/lilygo_tlora_pager/TLoraPagerBoard.cpp create mode 100644 variants/lilygo_tlora_pager/TLoraPagerBoard.h create mode 100644 variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv create mode 100644 variants/lilygo_tlora_pager/pins_arduino.h diff --git a/boards/lilygo-t-lora-pager.json b/boards/lilygo-t-lora-pager.json new file mode 100644 index 0000000..2393ecf --- /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", + "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/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp b/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp new file mode 100644 index 0000000..f3d8216 --- /dev/null +++ b/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp @@ -0,0 +1,51 @@ +// 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); + + if (io_expander.begin(Wire, PAGER_XL9555_ADDR)) { + // Enable the rails the rest of the app needs at boot: LoRa, GPS, keyboard + // (+ its reset line, held high in steady state), and the SD card. + const uint8_t rails[] = { + PAGER_EXPAND_LORA_EN, + PAGER_EXPAND_GPS_EN, + PAGER_EXPAND_KB_EN, + PAGER_EXPAND_KB_RST, + 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 trail-mate's bring-up order + } + delay(50); // let rails settle before anything downstream probes them + + io_expander.pinMode(PAGER_EXPAND_SD_DET, INPUT); + io_expander.pinMode(PAGER_EXPAND_SD_PULLEN, INPUT); + } + + 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..543b31d --- /dev/null +++ b/variants/lilygo_tlora_pager/TLoraPagerBoard.h @@ -0,0 +1,76 @@ +// 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 gating pager power rails (see pins_arduino.h and +// the hardware table in TLORA_PAGER_PORT.md). +#define PAGER_EXPAND_KB_RST 2 +#define PAGER_EXPAND_LORA_EN 3 +#define PAGER_EXPAND_GPS_EN 4 +#define PAGER_EXPAND_KB_EN 8 +#define PAGER_EXPAND_SD_DET 10 +#define PAGER_EXPAND_SD_PULLEN 11 +#define PAGER_EXPAND_SD_EN 12 + +#define PAGER_XL9555_ADDR 0x20 + +// 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"; + } + + // 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/pins_arduino.h b/variants/lilygo_tlora_pager/pins_arduino.h new file mode 100644 index 0000000..fd45133 --- /dev/null +++ b/variants/lilygo_tlora_pager/pins_arduino.h @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#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 */ From 82b6d3b56d5675fb43e0d5020e7804799cd29ea8 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 6 Jul 2026 14:22:51 -0600 Subject: [PATCH 02/48] pager: land LR1121 radio glue (target.h/.cpp + CustomLR1121 wrapper) CustomLR1121{,Wrapper}.h are adapted from the core fork's CustomLR1110 pair (upstream MeshCore has no LR1121 support yet to crib instead). target.cpp's radio_init() spells out the LR1121 bring-up sequence directly since there's no std_init() helper for this chip: begin() with the NodePrefs-driven params and TCXO voltage passed straight in, the DIO5/DIO6 RF-switch table, and a CRC-length override to match the other radio wrappers' wire-protocol framing. Nothing references these files yet, so the shipping T-Deck/Heltec V4 envs are unaffected. Signed-off-by: Tesso M Costa --- variants/lilygo_tlora_pager/CustomLR1121.h | 51 ++++++++++ .../lilygo_tlora_pager/CustomLR1121Wrapper.h | 49 ++++++++++ variants/lilygo_tlora_pager/target.cpp | 93 +++++++++++++++++++ variants/lilygo_tlora_pager/target.h | 31 +++++++ 4 files changed, 224 insertions(+) create mode 100644 variants/lilygo_tlora_pager/CustomLR1121.h create mode 100644 variants/lilygo_tlora_pager/CustomLR1121Wrapper.h create mode 100644 variants/lilygo_tlora_pager/target.cpp create mode 100644 variants/lilygo_tlora_pager/target.h 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..030e1f3 --- /dev/null +++ b/variants/lilygo_tlora_pager/CustomLR1121Wrapper.h @@ -0,0 +1,49 @@ +// 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" +#include "RadioLibWrappers.h" +#include "LR11x0Reset.h" + +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/target.cpp b/variants/lilygo_tlora_pager/target.cpp new file mode 100644 index 0000000..db6f6c4 --- /dev/null +++ b/variants/lilygo_tlora_pager/target.cpp @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include "target.h" + +TLoraPagerBoard board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); + +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. + + spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + +#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; +} + +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..3c66272 --- /dev/null +++ b/variants/lilygo_tlora_pager/target.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include "CustomLR1121Wrapper.h" +#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(); From 9d5df3001d9c4a5f2e6d5ebf44b5690f3dc73221 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 6 Jul 2026 14:24:33 -0600 Subject: [PATCH 03/48] pager: add port tracker + milestone playbook docs TLORA_PAGER_PORT.md is the living status/decisions/risks tracker for the T-LoRa Pager port; TLORA_PAGER_PORT_MILESTONES.md breaks the work into ordered, agent-executable milestones with gates. Kept off main until the port is further along. Signed-off-by: Tesso M Costa --- TLORA_PAGER_PORT.md | 287 +++++++++++++++++++++++ TLORA_PAGER_PORT_MILESTONES.md | 413 +++++++++++++++++++++++++++++++++ 2 files changed, 700 insertions(+) create mode 100644 TLORA_PAGER_PORT.md create mode 100644 TLORA_PAGER_PORT_MILESTONES.md diff --git a/TLORA_PAGER_PORT.md b/TLORA_PAGER_PORT.md new file mode 100644 index 0000000..705360c --- /dev/null +++ b/TLORA_PAGER_PORT.md @@ -0,0 +1,287 @@ +# 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: **planning — no hardware bring-up yet.** Board JSON verified +(`boards/lilygo-t-lora-pager.json`, landed). Milestone ① (variant skeleton) and +Milestone ② (LR1121 radio glue) landed: `variants/lilygo_tlora_pager/{pins_arduino.h, +partitions_tlora_pager_touch.csv, TLoraPagerBoard.h/.cpp, CustomLR1121.h, +CustomLR1121Wrapper.h, target.h/.cpp}`. Nothing references these files yet (no +platformio.ini env — that's M4), so both shipping envs build unchanged; the new +files are unverified by the compiler until then. Next = step ③ of the worklist +(ST7796 display driver). **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, RST −1, backlight 42 (AW9364 16-level stepped driver) | +| 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`) gates power rails: DRV_EN ch0, AMP_EN ch1, KB_RST ch2, LORA_EN ch3, GPS_EN ch4, NFC_EN ch5, GPS_RST ch7, KB_EN ch8, 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 (I2S 10/11/17/18/45) driving an **NS4150B** 3 W Class-D amp (enabled via expander AMP_EN ch1) | +| 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. +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` and the Heltec V4 already builds on TFT_eSPI, so + mirror that wiring (`USER_SETUP_LOADED` + `-D` pin set). Backlight is the AW9364 + (stepped pulse dimming), not a plain GPIO PWM — small driver, crib trail-mate. +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. + +## 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). +- [ ] ③ **Display**: `src/helpers/ui/ST7796LCDDisplay.{h,cpp}` on TFT_eSPI (`ST7796_DRIVER`, `TFT_WIDTH=222`, `TFT_HEIGHT=480`, MADCTL rotation for landscape) + AW9364 backlight helper. `DISPLAY_CLASS=ST7796LCDDisplay`. +- [ ] ④ **Env**: `[env:tlora_pager_lr1121_companion_radio_touch]` in `platformio.ini` — clone the T-Deck env, swap board/radio/display/input defines, add lib_deps: `adafruit/Adafruit TCA8418`, `lewisxhe/SensorLib`, `lewisxhe/XPowersLib`. **Compile gate: all three envs build.** +- [ ] ⑤ **Input drivers**: `src/helpers/input/PagerKeyboard.{h,cpp}` (TCA8418 INT-driven or polled; local keymap incl. shift/sym/alt — crib trail-mate's `LilyGoKeyboard` layout tables) and `PagerEncoder.{h,cpp}` (quadrature on 40/41 + press 7, ISR edge-counting like `TDeckTrackball`). Same pollable-API style; no LVGL in drivers. +- [ ] ⑥ **UITask wiring**: new cap block in `device_caps.h`; register KEYPAD indev via the Tanmatsu branch (~35706, widen its `#if` gate); drain keyboard → `handleHwKey()` + encoder → `navFifoPush` in the main loop (mirror the T-Deck drain at ~37320); add 480×222 to the `hor_res/ver_res` block (~35626, hardware MADCTL rotation — no sw_rotate needed); draw-buffer width 480 (`g_draw_buf_px`, ~1399); **222-px vertical audit**: `STATUSBAR_H`/`TABBAR_H`/`CHAT_KB_H` (~913–944) and modal/chat height helpers; on-screen keyboard suppressed (reuse the T-Deck path). +- [ ] ⑦ **Headless bring-up gate** (needs hardware): radio joins the live mesh (adverts seen both ways, ACKs verified — watch upstream LR1121 ACK issue meshcore-dev/MeshCore#1376); USB companion link passes the large device-profile frame (see HW-CDC note above); SPIFFS + SD storage OK. +- [ ] ⑧ **On-device UI pass**: 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. +- [ ] ⑨ **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. +- [ ] ⑩ (Optional, cheap) `tlora_pager_sx1262_...` env for SX1262-variant owners — same board JSON + variant, swap the four radio defines back to the T-Deck's SX1262 set. + +## UI-changes inventory (what actually changes in `src/ui-touch/`) + +| Area | Change | Size | +|---|---|---| +| Indev registration (~35706–35751) | Widen the Tanmatsu keypad-only branch's gate to the pager cap; do NOT register the pointer indev | small | +| Input drain (main loop ~37320) | Pager branch: `pagerKeyboardReadKey()`→`handleHwKey()`, encoder deltas→`navFifoPush(NEXT/PREV)`, press→ENTER | small | +| `handleHwKey()` (~27990) | Mostly reuse; verify nav-hotkey cluster makes sense on the pager's QWERTY layout | small | +| Resolution block (~35626) | New branch: 480×222 landscape (panel native is 222×480 portrait → MADCTL rotate, like T-Deck/V4) | small | +| Draw buffer (~1399) | Width 480 (PSRAM; ~larger stripe buffer, plenty of headroom in 8 MB) | trivial | +| Vertical budget | Audit `STATUSBAR_H`, `TABBAR_H`, `CHAT_KB_H`, modal/chat height helpers for 222 px — likely slimmer bars + no on-screen kb reclaim most of it | **the real work** | +| Focus-nav coverage | Screen-by-screen pass that every control is in `s_nav_group` (Tanmatsu already forced most of this) | medium, on-device | +| device_caps.h | New `CAP_*` block: no touch, hw keyboard, encoder, 480×222, SD, GPS | trivial | + +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. +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. +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. +7. **NVS-preserving flash chain** applies here too — 4-component flash, never the + merged image (wipes saved Wi-Fi creds). + +## 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) diff --git a/TLORA_PAGER_PORT_MILESTONES.md b/TLORA_PAGER_PORT_MILESTONES.md new file mode 100644 index 0000000..56a9fe0 --- /dev/null +++ b/TLORA_PAGER_PORT_MILESTONES.md @@ -0,0 +1,413 @@ +# T-LoRa Pager port — implementation milestones (agent playbook) + +This file turns `TLORA_PAGER_PORT.md` (the *why* + research) into an ordered set of +**self-contained milestones for a Claude Sonnet agent to execute one at a time**. +The operator says "execute Milestone N"; the agent implements exactly that phase, +verifies its gate, updates the trackers, and stops. + +--- + +## Operator instructions + +Prompt template per phase: + +> Read `CLAUDE.md`, `TLORA_PAGER_PORT.md`, and `TLORA_PAGER_PORT_MILESTONES.md`, +> then execute **Milestone N**. Do not start work from any other milestone. + +- Milestones are strictly ordered; each assumes the previous ones are merged. +- M1–M6 are pure software (no hardware needed). M7–M8 need the device on a desk + and a human in the loop. M9–M10 are follow-ups. +- One milestone = one focused commit/PR (repo rule: one topic per PR). + +## Global rules for the executing agent (apply to EVERY milestone) + +1. **Read first**: `CLAUDE.md` (repo guide), `TLORA_PAGER_PORT.md` (hardware facts, + pin map, decisions — treat it as the source of truth for pins/defines), + `CONTRIBUTING.md`. +2. **Never regress the shipping boards.** After your changes, BOTH existing envs + must build: + ```bash + pio run -e heltec_v4_tft_companion_radio_usb_tcp_touch + pio run -e LilyGo_TDeck_companion_radio_touch + ``` + From M4 onward, the pager env must build too: + ```bash + pio run -e tlora_pager_lr1121_companion_radio_touch + ``` +3. **No refactors — and match this repo's design patterns exactly.** Copy the + existing shapes (T-Deck variant, Tanmatsu UI branch, `TDeckKeyboard`-style + drivers). Do not introduce abstraction layers, do not reorganize existing + files, do not "clean up" unrelated code. Concretely, the conventions to mirror: + - **Naming**: board classes are `Board` (`TDeckBoard` → `TLoraPagerBoard`); + variant entry points are `target.{h,cpp}`; input-driver free functions carry a + board prefix (`tdeckKeyboardBegin/Poll/ReadKey` → `pagerKeyboardBegin/Poll/ReadKey`); + UI statics are `s_*`, LVGL globals hang off `g_lv`. + - **Comments explain WHY, with the hardware constraint or war story** — see the + HW-CDC note in `platformio.ini`, the PSRAM rationale atop `main.cpp`'s + `s_si_mem`, the crash-safe note on `DataStore::savePrefs`. New code documents + non-obvious constraints the same way; it never narrates what the next line does. + - **Header doc-comments state the driver contract** (who polls, which core, + ISR-safety) — model on `TDeckKeyboard.h` / `TDeckTrackball.h`. + - **Memory discipline**: internal DRAM is the scarce pool (Wi-Fi+BLE coexistence + needs ~50 KB free). Big new buffers/objects (keymap tables, frame buffers) go + to PSRAM the way `main.cpp` places `the_mesh` and the transport object. + - **Includes**: wadamesh's own copies of files that also exist in the core lib + use **quoted** includes (the `MC_VENDORED_TOUCH_APP` pattern — see the comment + at `main.cpp:15-17`); core-lib headers use angle brackets. + - **`platformio.ini` env blocks keep the same structure**: grouped `-D` sections + with `; --- section ---` banner comments, same ordering as the T-Deck env, and + inline why-comments on any value that differs from the sibling envs. + - **Feature gating**: board/capability `#if`s, never runtime flags, for anything + board-specific — matching how `HAS_TDECK_GT911`/`HAS_TANMATSU`/`HAS_EXPANSION_KIT` + are used today. +4. **Licensing**: new files you author get `// SPDX-License-Identifier: GPL-3.0-or-later`. + Files cribbed from upstream MeshCore keep their original **MIT** header verbatim — + never relicense them. +5. **No Claude/AI attribution** in commit messages or PR text. Sign-off per DCO is + the human's job; just don't add `Co-Authored-By: Claude` or similar. +6. **Pager-specific behavior must be gated** (new `#if` on the pager's board/cap + macros) so the other boards' binaries are behaviorally unchanged. +7. **Reference material** (read, don't copy blindly): + - Local working pager port: `~/dev/trail-mate/` — + `variants/lilygo_tlora_pager/pins_arduino.h` (pin map), + `boards/tlora_pager/src/tlora_pager_board.cpp` (`initLoRa()`, power rails, SD), + `boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h`. + - Upstream MeshCore (MIT crib source for radio wrappers + a second opinion on + everything): `https://github.com/meshcore-dev/MeshCore`, dirs + `variants/lilygo_tlora_pager/` and `src/helpers/radiolib/`. Fetch raw files + (shallow clone or raw.githubusercontent) — do NOT add it as a dependency. + - In-repo templates: `variants/lilygo_tdeck/*`, `variants/heltec_v4/*`, + `src/helpers/input/TDeck*`, the Tanmatsu branches inside `src/ui-touch/UITask.cpp`. +8. **When done**: tick the matching worklist box + refresh the `Status:` line in + `TLORA_PAGER_PORT.md`, note any deviation/discovery there (it's the living + tracker), and report build results honestly (paste failing output if red). +9. Line numbers cited below (e.g. `UITask.cpp ~35706`) were measured at beta_35 — + treat them as anchors, re-locate by searching the named symbols. + +--- + +## Milestone 1 — Variant skeleton (board class, pins, partitions) + +**Objective:** create `variants/lilygo_tlora_pager/` with everything except radio +and display, modeled on `variants/lilygo_tdeck/`. + +**Deliverables** +1. `variants/lilygo_tlora_pager/pins_arduino.h` — write our own (do NOT copy + trail-mate's: it brands `USB_PRODUCT "TRAIL MATE"`). Base it on the T-Deck's + `pins_arduino.h` shape; USB VID/PID `0x303A`/`0x82D4`, product string + `"wadamesh T-LoRa Pager"`. Pin values: see the hardware table in + `TLORA_PAGER_PORT.md` (I²C SDA 3 / SCL 2; shared SPI SCK 35 / MOSI 34 / MISO 33; + UART0 TX 43 / RX 44). +2. `variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv` — copy + `variants/lilygo_tdeck/partitions_tdeck_touch.csv` verbatim layout (2× 3.875 MB + OTA slots, tiles LittleFS 4.75 MB, spiffs 3.375 MB, coredump) — same 16 MB flash, + no reason to diverge. Update the header comment for the pager. +3. `variants/lilygo_tlora_pager/TLoraPagerBoard.h/.cpp` — `class TLoraPagerBoard : + public ESP32Board`, modeled on `TDeckBoard` but: + - `begin()`: `ESP32Board::begin()`, then bring up the **XL9555 IO expander** + (SensorLib `ExtensionIOXL9555`, I²C) and enable rails: LORA_EN(ch3), + GPS_EN(ch4), KB_EN(ch8)+KB_RST(ch2), SD rails (ch10–12) — mirror trail-mate's + power bring-up order. Handle deep-sleep wake reason like `TDeckBoard.cpp:6-34`. + - `getBattMilliVolts()`: query the **BQ27220 fuel gauge** (SensorLib + `GaugeBQ27220`) over I²C — NOT `analogReadMilliVolts` (there is no ADC divider). + Fall back to a sane constant (e.g. 3700) if the gauge probe failed, so the UI + never divides by zero. + - `getManufacturerName()`: `"LilyGo T-LoRa Pager"`. + - `enterDeepSleep()`/sleep: ext1 wake on `P_LORA_DIO_1` (GPIO 14) + BOOT (GPIO 0), + mirroring `TDeckBoard.h:26-47`. + - Keep the charger (BQ25896/XPowersLib) OUT of scope for now — note a TODO; + the gauge alone covers the UI battery display. + +**Not in this milestone:** `target.h/.cpp` (M2), display (M3), env (M4). Nothing +references the new files yet, so the two existing envs build unchanged. + +**Gate:** both existing envs build; `git status` shows only the new variant files. + +--- + +## Milestone 2 — LR1121 radio glue + +**Objective:** vendored LR1121 wrapper + the variant's `target.{h,cpp}`. + +**Deliverables** +1. `variants/lilygo_tlora_pager/CustomLR1121.h` + `CustomLR1121Wrapper.h` — + **re-check upstream `meshcore-dev/MeshCore` `src/helpers/radiolib/` first** + (as of 2026-07-06 it has no `CustomLR1121*` and no `variants/lilygo_tlora_pager/` + — issue #861 "Support for LR1121" is still open, so there's nothing to crib + yet; it may have landed by the time this milestone runs). If still absent, + author the pair by adapting the core fork's `CustomLR1110{,Wrapper}.h` + (`~/dev/MeshCore/src/helpers/radiolib/` or the pulled `core-v1.16.5` lib_dep) — + LR1110 and LR1121 share RadioLib's `LR11x0` base class, so this is a type + swap (`LR1110`→`LR1121`), not new logic. Cross-check the RF-switch table / + `setTCXO`/sync-word/preamble sequence against trail-mate's `initLoRa()` + (`~/dev/trail-mate/boards/tlora_pager/src/tlora_pager_board.cpp`), which + drives RadioLib's stock `LR1121` class directly on this exact device. They + subclass RadioLib's `LR1121` and the core's `RadioLibWrapper`, both already + on the include path; that's why they can live in the variant dir (zero + core-fork churn). +2. `variants/lilygo_tlora_pager/target.h` — mirror `variants/lilygo_tdeck/target.h`: + `RADIOLIB_STATIC_ONLY`, include the local wrapper headers (quoted includes), + externs for `board`, `radio_driver`, `radio`, `rtc_clock`, `sensors`, and the + `#ifdef DISPLAY_CLASS` display/user_btn block. +3. `variants/lilygo_tlora_pager/target.cpp` — mirror the T-Deck's: + - `RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);` + - `WRAPPER_CLASS radio_driver(radio, board);` + - `radio_init()`: RTC + `Wire.begin(3, 2)`, SPI begin on 35/33/34, then the + **LR1121-mandatory sequence** (no `std_init` exists for it): + `radio.begin(freq, bw, sf, cr, syncword, power, preamble)` with the same + NodePrefs-driven params the other targets use, then + `setRfSwitchTable` on **DIO5/DIO6** (`STBY {L,L} / RX {L,H} / TX {H,L} / + TX_HP {H,L}`, table verbatim from trail-mate `initLoRa()` / + upstream target). Pass `tcxoVoltage=3.0f` as `begin()`'s 8th arg directly + rather than calling `setTCXO(3.0f)` again afterward — `LR11x0::modSetup()` + already applies it internally, so a second call is a no-op (trail-mate's + `initLoRa()` only needs the follow-up call because it uses the zero-arg + `begin()`, whose default `tcxoVoltage` is 1.6 V, not 3.0 V). Match sync + word / preamble / CR to what `RadioLibWrappers.cpp` uses so the pager + interoperates with the live mesh, and add `setCRC(1)` after `begin()` + (LR11x0 defaults to a 2-byte CRC; every other MeshCore radio wrapper + overrides to 1 byte for wire-protocol interop). + - `radio_new_identity()` via `RadioNoiseListener` (copy T-Deck's actual code + — not `StdRNG`, which neither T-Deck nor Heltec V4 actually use for this). +4. GPS: wire `EnvironmentSensorManager` like the T-Deck (`ENV_INCLUDE_GPS`). + **TX/RX direction, resolved**: wadamesh's own core calls + `Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX)`, and `HardwareSerial::setPins()` + takes `(rxPin, txPin)` — so `PIN_GPS_TX` supplies the ESP's RX pin and + `PIN_GPS_RX` supplies the ESP's TX pin (named from the GPS module's + perspective). trail-mate's `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 native `(rxPin, txPin)` order, so its `GPS_RX` + already IS the ESP's RX pin). **Use `PIN_GPS_RX=12` / `PIN_GPS_TX=4`** — + trail-mate's raw values swapped, not copied verbatim. MIA-M10Q baud: + confirmed 38400 from trail-mate's `Serial1.begin(38400, ...)` (same as + T-Deck Plus). + +**Gate:** both existing envs build (pager files still unreferenced). Manually +re-read the RF-switch table against BOTH references — a wrong table silently kills +TX power (that class of bug cost the T-Deck ~16 dB once; see the +`SX126X_DIO2_AS_RF_SWITCH` war story in `platformio.ini`). + +--- + +## Milestone 3 — ST7796 display driver + backlight + +**Objective:** app-side `DISPLAY_CLASS` for the 480×222 panel. + +**Deliverables** +1. `src/helpers/ui/ST7796LCDDisplay.{h,cpp}` — implement the core's `DisplayDriver` + interface exactly as `ST7789LCDDisplay` does (find it in the core lib after a + build: `.pio/libdeps//MeshCore/src/helpers/ui/ST7789LCDDisplay.h`). The + LVGL path in `UITask.cpp` only calls: `begin()`, `width()`, `height()`, + `startFrame()`/`endFrame()`, `setDisplayRotation(int)`, + `writePixelsRGB565(x, y, w, h, buf)` (flush at `UITask.cpp` ~2069), plus what + `main.cpp:244+` uses for the boot screen. Build it on **TFT_eSPI** + (`ST7796_DRIVER`) the way the Heltec V4 wires TFT_eSPI via `USER_SETUP_LOADED` + `-D`s. Panel native is **222×480 portrait**; landscape 480×222 comes from + MADCTL rotation (`setRotation`), same approach as the existing boards. + Guard the whole TU with the pager's board macro so other envs don't compile it. +2. Backlight: the AW9364 is a **stepped one-wire dimmer** (pulse-counted levels, + 16 steps), not a PWM pin — small helper (crib the pulse timing from trail-mate), + either inside the display class or as `Aw9364Backlight.{h,cpp}` next to it. + Expose the same brightness hook `ST7789LCDDisplay` exposes so the existing + Settings brightness UI keeps working. + +**Gate:** both existing envs build. (The new TU is gated off for them; it first +compiles for real in M4 — expect to iterate on it then.) + +--- + +## Milestone 4 — PlatformIO env (FIRST FULL COMPILE — the integration milestone) + +**Objective:** `[env:tlora_pager_lr1121_companion_radio_touch]` builds the entire +app for the pager. This is where M1–M3 code meets the compiler; expect iteration +and fix M1–M3 files as needed (that's in-scope here). + +**Deliverables** +1. New env in `platformio.ini`, cloned from `[env:LilyGo_TDeck_companion_radio_touch]`, + with these deltas (everything not listed stays as the T-Deck has it — + MULTI_TRANSPORT_COMPANION, TCP/WS ports, BLE_PIN_CODE, MAX_CONTACTS, + LV_* flags, mbedTLS sizes, `extra_scripts`, RadioLib excludes, etc.): + - `board = lilygo-t-lora-pager`; + `board_build.partitions = variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv` + - Board: `-I variants/lilygo_tlora_pager`, `-D TLORA_PAGER=1` (new board macro), + drop `LILYGO_TDECK` + all T-Deck pins/caps (`HAS_TDECK_*`, `PIN_TB_*`, + `PIN_PERF_POWERON`). + - Radio: `-D USE_LR1121=1`, `RADIO_CLASS=CustomLR1121`, + `WRAPPER_CLASS=CustomLR1121Wrapper`, `P_LORA_NSS=36`, `P_LORA_RESET=47`, + `P_LORA_BUSY=48`, `P_LORA_DIO_1=14`, `P_LORA_SCLK=35`, `P_LORA_MISO=33`, + `P_LORA_MOSI=34`. Drop the SX126X-specific `-D`s (`SX126X_*`, `USE_SX1262`); + optionally add `RADIOLIB_EXCLUDE_SX126X=1`. + - Display: `-D DISPLAY_CLASS=ST7796LCDDisplay`, TFT_eSPI set: + `USER_SETUP_LOADED=1`, `ST7796_DRIVER=1`, `TFT_WIDTH=222`, `TFT_HEIGHT=480`, + `TFT_MOSI=34`, `TFT_SCLK=35`, `TFT_CS=38`, `TFT_DC=37`, `TFT_RST=-1`, + `TFT_MISO=33`, `TFT_BL=-1` (AW9364 owns brightness), `SPI_FREQUENCY` per + trail-mate's panel clock. + - Input/caps: `PIN_USER_BTN=0`; the pager cap defines you'll consume in M5/M6 + (suggest `HAS_PAGER_KEYBOARD=1`, `HAS_PAGER_ENCODER=1`). + - GPS: `ENV_INCLUDE_GPS=1`, `ENV_SKIP_GPS_DETECT=1`, `PIN_GPS_RX=12`, + `PIN_GPS_TX=4` (swapped vs. trail-mate's raw values — see M2's deliverable + ④ for why), `GPS_BAUD_RATE=38400`. + - `FIRMWARE_OTA_ENV='"tlora_pager_lr1121_companion_radio_touch"'`. + - `build_src_filter`: T-Deck's list with `+<../variants/lilygo_tlora_pager/*.cpp>`. + - `lib_deps`: T-Deck's list **plus** `bodmer/TFT_eSPI @ ^2.5.43`, + `adafruit/Adafruit TCA8418 @ ^1.0.2`, `lewisxhe/SensorLib` (pin the latest + release), and (only if M1 ended up needing it) `lewisxhe/XPowersLib`. + - Do NOT add the env to `default_envs` yet (keeps `pio run` = the two shipping + boards until the port stabilizes). +2. Whatever fixes M1–M3 files need to make it link. + +**Gate (hard):** all **three** envs build green. Record flash/RAM usage of the +pager build in the tracker. + +--- + +## Milestone 5 — Input drivers (TCA8418 keyboard + rotary encoder) + +**Objective:** pollable drivers in the established `src/helpers/input/` style +(begin/poll/read API, ring buffers, **zero LVGL inside drivers**). + +**Deliverables** +1. `src/helpers/input/PagerKeyboard.{h,cpp}` — TCA8418 over I²C (Adafruit lib), + INT pin 6 (prefer INT-driven drain; polled fallback fine). API mirrors + `TDeckKeyboard.h`: `pagerKeyboardBegin()`, `pagerKeyboardPoll()`, + `pagerKeyboardReadKey()` → **final ASCII/control codes** — i.e. the keymap + + shift/sym/alt state machine lives HERE, so the UI sees the same char stream the + T-Deck's C3 keyboard produces and `handleHwKey()` needs no changes. Crib the + matrix→ASCII layout tables from trail-mate's `LilyGoKeyboard` (find it under + `~/dev/trail-mate`; it's the working reference for this exact hardware). + Include keyboard backlight control (pin 46) mirroring + `tdeckKeyboardSetBacklight()` so the existing settings hook can drive it. +2. `src/helpers/input/PagerEncoder.{h,cpp}` — quadrature on A=40/B=41 with ISR + edge-counting exactly like `TDeckTrackball.cpp:27-47`, press on GPIO 7 with + held-state API. Suggested API: `pagerEncoderBegin()`, + `pagerEncoderReadDelta()` (signed detents since last call), + `pagerEncoderClickHeld()`. +3. Both TUs gated by their cap macros (`HAS_PAGER_KEYBOARD` / `HAS_PAGER_ENCODER`); + already picked up by the M4 `build_src_filter` (`+`). + +**Gate:** all three envs build. Driver headers carry a short doc comment stating +the contract (like `TDeckTrackball.h` does). + +--- + +## Milestone 6 — UITask wiring (caps, indev, resolution, 222-px layout) + +**Objective:** the pager becomes a first-class UI target. This is the largest +milestone — work through it in the order below, keeping every change behind the +pager gate. Line anchors are beta_35-era; search for the symbols. + +**Deliverables** +1. `src/ui-touch/device_caps.h` — new pager block: no touch, hw keyboard, encoder, + 480×222, SD, GPS. Follow the existing cap style; wire `HAS_PAGER_KEYBOARD`/ + `HAS_PAGER_ENCODER`/`TLORA_PAGER` into the caps the UI actually branches on. +2. **Indev registration** (`UITask.cpp` ~35706–35751): extend the Tanmatsu + keypad-only branch (KEYPAD indev + `s_nav_group`, read_cb `tanmatsuKeypadRead`) + to also cover the pager cap. Do NOT register the POINTER indev for the pager. +3. **Input drain** (main loop, mirror the T-Deck drain at ~37320): pager branch — + `pagerKeyboardPoll()` + `while ((k = pagerKeyboardReadKey())) handleHwKey(k);` + encoder: `pagerEncoderReadDelta()` → `navFifoPush(LV_KEY_NEXT/PREV)` per detent, + click → ENTER via the FIFO, long-press → ESC. Respect `s_nav_ta_editing` + semantics already implemented in `handleHwKey()` (~27990) — no changes expected + there beyond verifying the nav-hotkey cluster doesn't collide with pager typing. +4. **Resolution** (~35626): new branch — pager is fixed 480×222 landscape via + hardware MADCTL rotation (`hor_res=480, ver_res=222`, no `sw_rotate`, no + portrait mode). Draw buffer (~1399): width 480. +5. **222-px vertical audit** — the real work. Budget: status bar + tab bar + + content in 222 px, no on-screen keyboard ever (reuse the T-Deck suppression, + ~1153/2691/28005). Review `STATUSBAR_H`, `TABBAR_H`, `CHAT_KB_H` (~913–944) and + the modal/chat height helpers (`tabContentW/H`, `modalAvailW/H`, + `chatScreenW/H`); introduce pager-gated slimmer values where 222 px overflows. + Screens to check compile-time/desk-level (full on-device pass is M8): Home, + chat thread + compose, contacts list, settings list, modals/toasts. +6. Boot screen: `main.cpp:263-267` paints the wordmark via `writePixelsRGB565` — + make sure the centering math handles 480×222. + +**Gate:** all three envs build; T-Deck/V4 binaries behaviorally unchanged (every +edit sits behind the pager gate — spot-check the diff for un-gated changes). +Update the tracker's UI-inventory table with what was actually touched. + +--- + +## Milestone 7 — Headless hardware bring-up (needs device + human) + +**Objective:** prove radio, storage, and companion link on real hardware before +polishing UI. The agent prepares, flashes, and reads logs; the human handles the +physical device and the second mesh node. + +**Checklist** +1. Flash with the **4-component chain** (`0x0/0x8000/0xe000/0x10000`) — NEVER the + merged image (wipes NVS). `pio run -t upload -e tlora_pager_lr1121_companion_radio_touch` + or esptool with the four artifacts. +2. Serial monitor (115200): clean boot, XL9555 rails up, gauge probe result, + SPIFFS mounts, SD detect (if card present), GPS NMEA flowing. +3. **Radio gate**: against a known-good node (T-Deck/V4 on the same freq/bw/sf): + adverts seen BOTH directions; DM with ACK round-trip verified several times — + this specifically probes upstream LR1121 ACK issue + (meshcore-dev/MeshCore#1376, still open as of 2026-07-06). If ACKs fail, + check whether upstream has since landed `CustomLR1121`/a pager target (it + had not as of this writing — see TLORA_PAGER_PORT.md's Decision ② caveat) + for post-issue fixes before debugging locally. +4. **HW-CDC gate**: connect the companion app over USB; the large device-profile + frame (node name + keys) must arrive intact. If bytes drop (the Heltec V4 + regression), rebuild with the board JSON's `ARDUINO_USB_MODE` overridden back + to TinyUSB CDC and record the decision in the tracker. +5. Record RSSI/SNR sanity, TX current draw if measurable, and flash/RAM headroom. + +**Gate:** all four checklist gates pass, results logged in the tracker. + +--- + +## Milestone 8 — On-device UI pass + +**Objective:** every screen usable with encoder + QWERTY only. + +**Checklist** (drive each screen on hardware; fix behind the pager gate): +- Focus-nav coverage: every interactive control reachable in `s_nav_group` + (`navMaybeRebuild` per-screen collection) — tabs, lists, buttons, toggles, + text fields, modals, action sheets. +- Chat at 222 px: thread list, bubbles, compose flow (focus field → type on QWERTY + → send), per-message info sheet. +- Long lists (contacts up to 2000): encoder NEXT/PREV walk is tolerable; if not, + add pager-gated page-jump keys via `navMoveDir` (risk #5 in the tracker). +- Map: pan via nav keys/encoder, zoom keys. +- Settings: brightness (AW9364 steps), keyboard backlight, sleep/wake (encoder or + BOOT wakes), lock screen. +- Fonts/legibility at 480-wide; screenshot or photograph anything questionable for + the human to judge. + +**Gate:** human sign-off screen-by-screen; deviations logged in the tracker. + +--- + +## Milestone 9 — Release pipeline + +**Objective:** the pager ships through the existing two-channel release flow. + +**Deliverables** +1. `scripts/release.sh`: add `tlora_pager_lr1121_companion_radio_touch:wadamesh-tlora-pager` + to `ENVS`. +2. `deploy/flasher/manifest-tlora-pager.json` + flasher page entry (mirror the + T-Deck manifest; 4-component chain offsets). +3. Verify `merge-bin.py` + `scripts/build/gen-flasher-meta.py` handle the third + env (they iterate `ENVS`/manifests — check assumptions). +4. Add the env to `default_envs` now that it's shipping. +5. Do NOT touch the Mesh America catalog or LauncherHub — separate decisions. + +**Gate:** a dry-run `scripts/release.sh beta_` (without `WADAMESH_VPS` set) +stages all three boards' artifacts locally; human reviews before any real cut. + +--- + +## Milestone 10 (optional) — SX1262-variant env + +**Objective:** serve pager units sold with SX1262 instead of LR1121. + +Clone the M4 env as `tlora_pager_sx1262_companion_radio_touch`: same board JSON, +same variant dir, swap the radio block back to the T-Deck's SX1262 set +(`USE_SX1262`, `RADIO_CLASS=CustomSX1262`, `WRAPPER_CLASS=CustomSX1262Wrapper`, +same NSS/RESET/BUSY/DIO1 pins, plus the `SX126X_*` defines — check trail-mate's +SX1262 pager env for TCXO/DIO2-switch values on this board; do NOT assume the +T-Deck's). `target.cpp` needs a small `#if` around the LR1121-only init sequence. + +**Gate:** all envs build; radio gate (M7 step 3) re-run on SX1262 hardware when +available. From 546cc9079adf813d466be5538f596bd1407c5cad Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 6 Jul 2026 15:10:18 -0600 Subject: [PATCH 04/48] pager: add ST7796 display driver + AW9364 backlight (src/helpers/ui) ST7796LCDDisplay implements the core's DisplayDriver interface on TFT_eSPI for the pager's 480x222 landscape panel -- the first real TFT_eSPI consumer in this codebase, since ST7789LCDDisplay (the sibling this was modeled on) turns out to be Adafruit_GFX-based for both Heltec V4 and T-Deck. Every TFT_eSPI call was verified against the pinned library source rather than assumed. Backlight is the AW9364 stepped pulse-dimmer, wrapped directly in the display class via SensorLib's AW9364LedDriver (already a dependency) rather than a hand-rolled pulse timer, exposing a pct-based setBrightness()/getBrightness() hook for a later UITask.cpp wiring pass. Also adds a compile-time guard for CGRAM_OFFSET: this panel's 222px glass is narrower than the ST7796 controller's native GRAM, and TFT_eSPI only applies the required coordinate offset when that flag is set. Without the guard, a missing flag would build clean but silently shift every frame by 49px. Nothing references these files yet, so the shipping T-Deck/Heltec V4 envs are unaffected. Signed-off-by: Tesso M Costa --- NOTICE | 7 +- src/helpers/ui/ST7796LCDDisplay.cpp | 138 ++++++++++++++++++++++++++++ src/helpers/ui/ST7796LCDDisplay.h | 72 +++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/helpers/ui/ST7796LCDDisplay.cpp create mode 100644 src/helpers/ui/ST7796LCDDisplay.h diff --git a/NOTICE b/NOTICE index fb59442..eea62b8 100644 --- a/NOTICE +++ b/NOTICE @@ -51,14 +51,17 @@ 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 + This list is not exhaustive and is expanded as dependencies are vendored or added. diff --git a/src/helpers/ui/ST7796LCDDisplay.cpp b/src/helpers/ui/ST7796LCDDisplay.cpp new file mode 100644 index 0000000..c20db8f --- /dev/null +++ b/src/helpers/ui/ST7796LCDDisplay.cpp @@ -0,0 +1,138 @@ +// 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 + 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) { + setBrightness(0); + display.writecommand(TFT_DISPOFF); // TFT_RST=-1 on this board (not wired), so sleep via command, not reset pin + _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 From ae690e3fbb787dc2232de06f260d780e64a33e38 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 6 Jul 2026 16:14:00 -0600 Subject: [PATCH 05/48] pager: add platformio.ini env (first full compile) [env:tlora_pager_lr1121_companion_radio_touch], cloned from the T-Deck env with board/radio/display/GPS deltas for the LR1121 + ST7796 pager. Two structural fixes were needed to actually get this to compile: - pins_arduino.h moves to its own variants/lilygo_tlora_pager_pins/ folder (board JSON's "variant" field updated to match). PlatformIO's arduino-esp32 build script unconditionally compiles every source file under board_build.variants_dir// as a separate framework-level library with none of our own lib_deps -- since this board (unlike T-Deck/Heltec, which point at framework-bundled variants) has no framework-bundled variant of its own, our variants_dir override was colliding with our own board-glue files and failing to find Wire.h/RadioLib.h. - CustomLR1121Wrapper.h's bare quoted includes of RadioLibWrappers.h/ LR11x0Reset.h only resolve for core-lib files living in the same directory as their targets; switched to angle-bracket includes with the full core-lib subpath. Also required one small, forced addition to UITask.cpp: its display class selection only recognized the Tanmatsu vs. everything-else split, which made this env's own hard compile gate unreachable. Added a single #elif arm for the pager's display type: no other UITask.cpp behavior changes, indev/resolution/layout wiring is still a later milestone. All three envs (Heltec V4, T-Deck, pager) build green. Signed-off-by: Tesso M Costa --- boards/lilygo-t-lora-pager.json | 2 +- platformio.ini | 174 ++++++++++++++++++ src/ui-touch/UITask.cpp | 4 + .../lilygo_tlora_pager/CustomLR1121Wrapper.h | 9 +- .../pins_arduino.h | 11 ++ 5 files changed, 197 insertions(+), 3 deletions(-) rename variants/{lilygo_tlora_pager => lilygo_tlora_pager_pins}/pins_arduino.h (73%) diff --git a/boards/lilygo-t-lora-pager.json b/boards/lilygo-t-lora-pager.json index 2393ecf..0512256 100644 --- a/boards/lilygo-t-lora-pager.json +++ b/boards/lilygo-t-lora-pager.json @@ -18,7 +18,7 @@ "flash_mode": "qio", "hwids": [["0x303A", "0x82D4"]], "mcu": "esp32s3", - "variant": "lilygo_tlora_pager", + "variant": "lilygo_tlora_pager_pins", "variants_dir": "variants" }, "connectivity": ["wifi"], diff --git a/platformio.ini b/platformio.ini index 0ac9a99..73c417c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -381,3 +381,177 @@ lib_deps = lvgl/lvgl @ ^8.3.11 densaugeo/base64 @ ~1.4.0 knolleary/PubSubClient @ ^2.8.0 + +[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 + ; Conservative boot default (matches Heltec V4's default and trail-mate's own + ; fallback) -- real RF power tuning happens on hardware in a later milestone. + -D LORA_TX_POWER=10 + -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 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 + -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 diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 017118e..c40fec2 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -95,6 +95,8 @@ #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 #else #include #endif @@ -123,6 +125,8 @@ #endif #if defined(HAS_TANMATSU) extern TanmatsuDisplay display; + #elif defined(TLORA_PAGER) + extern ST7796LCDDisplay display; #else extern ST7789LCDDisplay display; #endif diff --git a/variants/lilygo_tlora_pager/CustomLR1121Wrapper.h b/variants/lilygo_tlora_pager/CustomLR1121Wrapper.h index 030e1f3..85055db 100644 --- a/variants/lilygo_tlora_pager/CustomLR1121Wrapper.h +++ b/variants/lilygo_tlora_pager/CustomLR1121Wrapper.h @@ -5,8 +5,13 @@ #pragma once #include "CustomLR1121.h" -#include "RadioLibWrappers.h" -#include "LR11x0Reset.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: diff --git a/variants/lilygo_tlora_pager/pins_arduino.h b/variants/lilygo_tlora_pager_pins/pins_arduino.h similarity index 73% rename from variants/lilygo_tlora_pager/pins_arduino.h rename to variants/lilygo_tlora_pager_pins/pins_arduino.h index fd45133..cda8613 100644 --- a/variants/lilygo_tlora_pager/pins_arduino.h +++ b/variants/lilygo_tlora_pager_pins/pins_arduino.h @@ -1,4 +1,15 @@ // 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 From 3e71867e71664d8826ee5439526cac668d7da4ac Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 6 Jul 2026 16:35:11 -0600 Subject: [PATCH 06/48] pager: add TCA8418 keyboard + rotary encoder drivers PagerKeyboard.{h,cpp}: TCA8418 matrix keyboard over I2C. Unlike the T-Deck's keyboard (a second MCU that resolves ASCII itself), the TCA8418 only reports raw row/col events, so the keymap and shift/sym/alt state machine live here -- keymap tables cribbed from trail-mate's LilyGoKeyboard (same physical PCB). Press/release polarity verified against the TCA8418 datasheet directly, since it disagrees with the Adafruit library's own header comment. Backlight is direct LEDC PWM on GPIO 46, using this framework version's actual channel-based API (confirmed before writing the call, not assumed). PagerEncoder.{h,cpp}: true A/B quadrature decode via a standard Gray-code transition table, ISR-cheap-arithmetic-only like TDeckTrackball's pattern (that driver's 4 discrete direction pins aren't actually quadrature, so its exact shape doesn't transfer, only the ISR/snapshot-read style does). Detent scaling (4 raw transitions per detent) is an unverified assumption pending hardware bring-up. Both gated on HAS_PAGER_KEYBOARD/HAS_PAGER_ENCODER, already reached by every env's existing helpers/input build filter. All three envs build green. Signed-off-by: Tesso M Costa --- platformio.ini | 5 ++ src/helpers/input/PagerEncoder.cpp | 71 ++++++++++++++++ src/helpers/input/PagerEncoder.h | 26 ++++++ src/helpers/input/PagerKeyboard.cpp | 125 ++++++++++++++++++++++++++++ src/helpers/input/PagerKeyboard.h | 40 +++++++++ 5 files changed, 267 insertions(+) create mode 100644 src/helpers/input/PagerEncoder.cpp create mode 100644 src/helpers/input/PagerEncoder.h create mode 100644 src/helpers/input/PagerKeyboard.cpp create mode 100644 src/helpers/input/PagerKeyboard.h diff --git a/platformio.ini b/platformio.ini index 73c417c..d047f04 100644 --- a/platformio.ini +++ b/platformio.ini @@ -473,6 +473,11 @@ build_flags = -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 -D ENABLE_ADVERT_ON_BOOT=0 -D LV_CONF_PATH=lv_conf.h -D LV_CONF_INCLUDE_SIMPLE=1 diff --git a/src/helpers/input/PagerEncoder.cpp b/src/helpers/input/PagerEncoder.cpp new file mode 100644 index 0000000..deaf4fc --- /dev/null +++ b/src/helpers/input/PagerEncoder.cpp @@ -0,0 +1,71 @@ +// 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(); + 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..35cf042 --- /dev/null +++ b/src/helpers/input/PagerKeyboard.cpp @@ -0,0 +1,125 @@ +// 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', '\n'}, + {'\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); Caps is a press-to-toggle letter-case lock — same UX trail-mate +// already validated on this exact keyboard. +static constexpr uint8_t kAltPos = 2 * KB_COLS + 0; // row2,col0 ('\0' in both layers) +static constexpr uint8_t kCapsPos = 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 Adafruit_TCA8418 s_kb; +static bool s_inited = false; +static bool s_alt = false; +static bool s_caps = 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) { s_alt = pressed; continue; } + if (code == kCapsPos) { if (pressed) s_caps = !s_caps; continue; } + if (code == kBackspacePos) { if (pressed) ringPush('\b'); 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; + if (s_caps && !s_alt) c = (char)toupper((unsigned char)c); // caps affects the base layer only, matching trail-mate + 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); +} + +#endif diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h new file mode 100644 index 0000000..d0a4b92 --- /dev/null +++ b/src/helpers/input/PagerKeyboard.h @@ -0,0 +1,40 @@ +// 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); + +#endif From 641700fad007bd1b569566d27bc8441128efcf80 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 6 Jul 2026 19:06:31 -0600 Subject: [PATCH 07/48] touch: wire T-LoRa Pager into UITask (M6) Adds the pager as a first-class non-touch UI target: new device_caps.h block (no touch, hw keyboard + rotary-encoder nav, 480x222, GPS/OTA/lock screen; SD/filesystem left off since the mount code is still hardcoded to HAS_TDECK_GT911, not genuinely CAP_SD-generic), KEYPAD indev registration reusing the Tanmatsu keypad plumbing, forced 480x222 landscape rotation, keyboard/encoder input drain in the main loop, and AW9364-correct brightness wiring ahead of the existing PWM backlight branch. Also fixes two latent gaps this surfaced: navMaybeRebuild() was unreachable for the pager's cap combination (would have left the focus group permanently empty), and several HAS_TDECK_KEYBOARD-gated helpers needed their reopened regions individually widened rather than the file touched wholesale. All three envs build green; Heltec V4 TFT and T-Deck binaries are byte-identical to pre-change (25.3%/73.5%), confirming every edit stayed behind the pager gate. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 151 +++++++++++++++++++++++++++++++++---- src/ui-touch/device_caps.h | 28 ++++++- 2 files changed, 161 insertions(+), 18 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c40fec2..506c421 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -89,6 +89,12 @@ #if defined(HAS_TDECK_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) @@ -2602,9 +2608,13 @@ static void navScrollFocused(bool up) { } // 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; @@ -2627,6 +2637,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) { @@ -26878,6 +26889,37 @@ static void updateTrackball(unsigned long now) { } #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(); + 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; + + const bool held = pagerEncoderClickHeld(); + if (held && !s_was_held) { + s_press_start = now; + s_long_fired = false; + } else if (held && !s_long_fired && (now - s_press_start) >= kLongPressMs) { + navPushTap(LV_KEY_ESC); + s_long_fired = true; + } else if (!held && s_was_held && !s_long_fired) { + navPushTap(LV_KEY_ENTER); // released before the long-press threshold -> short click + } + 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. @@ -26906,10 +26948,12 @@ static bool anyPopupOpen() { return popupRegistryAny(); } static bool hwKeyDismissTopPopup() { return popupRegistryDismissTop(); } #endif // HAS_TDECK_KEYBOARD || HAS_TANMATSU (hwKeyDismissTopPopup) -#if defined(HAS_TDECK_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) +// 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 @@ -26919,6 +26963,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 @@ -26933,7 +26985,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 (keyboard helpers; the lock screen below is top-level) #if CAP_LOCK_SCREEN // ---- Lock screen ------------------------------------------------------------- @@ -27911,7 +27963,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) +// 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) #if defined(HAS_TDECK_KEYBOARD) // ---- Spacebar lock countdown ------------------------------------------------- @@ -28869,7 +28926,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; @@ -35605,6 +35676,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // MIPI-DSI panel is portrait-native (480x800); the device is used in landscape. badge-bsp's // 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 // Apply the saved backlight brightness (takes the LEDA pin over from the // display's digitalWrite via LEDC PWM). Both touch boards have the LEDA pin. @@ -35641,6 +35718,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // stays == the panel width, so it's never entered). g_lv.disp_drv.hor_res = TAN_PANEL_PW; // 480 g_lv.disp_drv.ver_res = TAN_PANEL_PH; // 800 +#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 @@ -35698,6 +35781,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.) @@ -35707,18 +35796,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 lv_indev_drv_init(&g_lv.indev_drv); g_lv.indev_drv.type = LV_INDEV_TYPE_POINTER; @@ -36435,7 +36529,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 @@ -37320,6 +37424,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. @@ -37359,6 +37465,19 @@ 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) + // Simpler than the T-Deck's: no keyboard-backlight-mode timer or spacebar-lock + // countdown wiring yet (pagerKeyboardSetBacklight() exists but isn't hooked up + // here, and this board's lock trigger -- if any -- isn't the spacebar, which + // is a real typing key on a full QWERTY, unlike the T-Deck's sparse layout). + // No separate core-0 touch task to own the I2C bus either (no touch at all), + // so poll and drain right here, once per tick. + pagerKeyboardPoll(); + for (int kbi = 0; kbi < 12; ++kbi) { + int key = pagerKeyboardReadKey(); + if (key <= 0) break; + handleHwKey(key); + } #endif #if defined(HAS_TANMATSU) // Drive the keyboard backlight from off/on/auto + the Keys-slider brightness; keep it @@ -37529,6 +37648,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_TRACKBALL // Keep the focus group synced whenever EITHER nav mode is active: the keyboard ESDFX nav // (fed by handleHwKey) or the trackball D-pad nav (fed by updateTrackball -> navMoveDir). diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index 7a69d96..a907bdc 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) @@ -57,15 +75,17 @@ #endif // ---- Derived input capabilities --------------------------------------------- -// Physical keyboard: T-Deck matrix OR Tanmatsu keypad. -#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_TANMATSU) +// Physical keyboard: T-Deck matrix, Tanmatsu keypad, or the pager's TCA8418. +#if defined(HAS_TDECK_KEYBOARD) || defined(HAS_TANMATSU) || defined(HAS_PAGER_KEYBOARD) #define CAP_KEYBOARD 1 #else #define CAP_KEYBOARD 0 #endif -// Focus-group D-pad navigation (no pointer): Tanmatsu keypad OR T-Deck trackball. -#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL) +// Focus-group D-pad navigation (no pointer): Tanmatsu keypad, T-Deck trackball, +// or 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). +#if defined(HAS_TANMATSU) || defined(HAS_TDECK_TRACKBALL) || defined(TLORA_PAGER) #define CAP_KEYPAD_NAV 1 #else #define CAP_KEYPAD_NAV 0 From 78a6c01f951caf183737d4727bdd2d450fc11738 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 09:44:36 -0600 Subject: [PATCH 08/48] pager: fix M7 hardware bring-up bugs, verify UI/keyboard/encoder on device Six real bugs found and fixed across two hardware sessions (full account in TLORA_PAGER_M7_HW_DEBUG_LOG.md): stale-NVS BLE bond crash, no screen-wake path for keyboard/encoder input, wrong TFT_eSPI SPI-port index on ESP32-S3, missing panel INVON for this ST7796 batch, two SPI hosts fighting over the shared radio/display pins, and a floating XL9555 ch6 panel hardware-reset line (the actual root cause of the intermittent black screen, not the SPI race originally suspected). TLoraPagerBoard::begin() now drives the full vendor rail set including the display reset pulse and parks shared-SPI selects high before first bus traffic. Boot logo, full LVGL UI, QWERTY keyboard navigation, and rotary encoder nav/select are now verified working on real hardware. Trackers updated: Milestone 7's UI portion is done; the remaining radio/USB-companion/SD gates move to a new Milestone 7b so they don't block Milestone 8's on-device UI pass, which is now active. All three envs (Heltec V4, T-Deck, pager) build green. Signed-off-by: Tesso M Costa --- TLORA_PAGER_M7_HW_DEBUG_LOG.md | 462 ++++++++++++++++++ TLORA_PAGER_PORT.md | 403 +++++++++++++-- TLORA_PAGER_PORT_MILESTONES.md | 308 +++++++++--- platformio.ini | 19 + src/helpers/ui/ST7796LCDDisplay.cpp | 27 +- src/main.cpp | 1 + src/ui-touch/UITask.cpp | 39 +- .../lilygo_tlora_pager/TLoraPagerBoard.cpp | 48 +- variants/lilygo_tlora_pager/TLoraPagerBoard.h | 26 +- variants/lilygo_tlora_pager/target.cpp | 24 +- 10 files changed, 1227 insertions(+), 130 deletions(-) create mode 100644 TLORA_PAGER_M7_HW_DEBUG_LOG.md diff --git a/TLORA_PAGER_M7_HW_DEBUG_LOG.md b/TLORA_PAGER_M7_HW_DEBUG_LOG.md new file mode 100644 index 0000000..0b5a49f --- /dev/null +++ b/TLORA_PAGER_M7_HW_DEBUG_LOG.md @@ -0,0 +1,462 @@ +# T-LoRa Pager — M7 hardware bring-up debug log + +## Session 2 (2026-07-07) — root cause found: floating panel-reset line (XL9555 ch6) + +Picked up from session 1's black-screen regression (§11 below). Two desk-side +findings changed the picture entirely: + +**Session 1's leading theory (§11, radio/display SPI race) is DISPROVEN, from +source:** TFT_eSPI's `Processors/TFT_eSPI_ESP32_S3.h:41-43` force-defines +`SUPPORT_TRANSACTIONS` ("mandatory for ESP32 so the hal mutex is toggled") — +its raw-register fast path is still bracketed by `spi.beginTransaction()` on +the same HAL mutex RadioLib uses on the shared `SPIClass`. On top of that, +`the_mesh.loop()` and `ui_task.loop()` run on the same task and RadioLib does +no SPI from ISRs, so there is no radio/display SPI concurrency at all. Don't +re-chase this. + +**BUG #6 (fixed — believed to be §11's real root cause): the ST7796's hardware +reset line IS wired — to XL9555 channel 6 — and we left it floating.** +Session 1 concluded "no display-specific expander channel exists" from the +LilyGoLib doc's channel table and the canonical arduino-esp32 master +`pins_arduino.h` — both list channels 0-5 and 7-12 and silently omit ch6. +LilyGoLib's own board code (`src/LilyGo_LoRa_Pager.cpp`, `begin()`) defines +`EXPANDS_DISP_RST` (=6, confirmed via forks carrying the newer pins header), +drives it HIGH with the other rails, then pulses it LOW→50ms→HIGH as a real +panel hardware reset before display init. Our port never touched ch6, so the +panel's reset floated at the expander's power-on high-Z default — a floating +active-low reset is exactly the observed failure shape: intermittent black +screen with a clean boot log (a controller held in hardware reset ignores ALL +SPI, including TFT_eSPI's software-reset fallback), no code-change +boot-to-boot variance, and worst behavior on a genuine cold power cycle. +(trail-mate gets away without it because its older vendored pins header lacks +the macro, so its `#ifdef EXPANDS_DISP_RST` blocks compile out — hardware +revisions likely differ in how hard the line floats.) + +**Fixes applied this session** (all in the working tree): +- `variants/lilygo_tlora_pager/TLoraPagerBoard.{h,cpp}`: full vendor rail set + (added DISP_RST ch6, NFC_EN ch5, DRV_EN ch0, AMP_EN ch1, GPS_RST ch7, + GPIO_EN ch9 to the existing five) — vendor drives everything HIGH, and + unpowered-but-bus-connected chips (DRV2605 + ES8311 on I2C, ST25R3916 on + the shared SPI) can clamp a shared bus through their ESD diodes, so + "off because unused" wasn't safe. Then the vendor's DISP_RST pulse + (LOW→50ms→HIGH). Also parks the shared-SPI selects/resets OUTPUT-HIGH + before any bus traffic (LORA_NSS 36, LORA_RST 47, SD_CS 21, NFC_CS 39 — + LilyGoLib's `initShareSPIPins()` equivalent, which we'd skipped entirely). +- `src/helpers/ui/ST7796LCDDisplay.cpp`: `turnOff()` no longer latches the + cached brightness at 0 (a `turnOn()` would have restored 0% backlight). + Latent — UITask's pager screen-off path doesn't use turnOff() — but fixed + so it can't muddy future testing. + +**Flashing note (adds to session 1's recipe):** app-only flashing works and +is what you want for iteration (`write_flash 0x10000 firmware.bin` — leaves +bootloader/partitions/NVS untouched), but the ROM loader needs the explicit +`--flash_mode qio --flash_freq 80m --flash_size 16MB` args even for app-only +writes — without them `--no-stub` fails at erase with +`Failed to enter Flash download mode (result was 01060000)`. + +**Open observation (not yet diagnosed):** after long uptime the board emitted +`[STALL] ui:gps 1644ms` continuously (every loop pass), vs. the normal single +~835ms line right after boot. The `ui:gps` checkpoint actually covers +everything from `updateGpsLocation()` to `uiCp("ui:verchk")` — including the +TCA8418 keyboard I2C poll — so repeated 1.6s stalls smell like I2C +transactions timing out (possibly the unpowered-DRV2605/ES8311 clamping +theory above; the full vendor rail set may have fixed this too). Watch for it +after long uptime on the new build; if it recurs, add finer uiCp checkpoints +inside that span. + +**Verified this session:** build green, flashed, boot log clean +(`[BOOT] ui ready`, single 836ms gps stall). Awaiting visual confirmation on +the glass + repeated cold-power-cycle testing (the one test that +discriminates: session 1's black screens were cold-boot-worst). + +**Session 2 outcome — WORKING, user-confirmed on the glass.** Sequence of +events after the first fix flash, worth keeping straight because it created a +misleading data point: +- User reported "still black" after the first DISP_RST-fix flash, then + flashed the official MeshCore pager image and confirmed the screen works — + proving the hardware (glass/backlight/FPC) was fine all along. +- When the board came back to this machine it was **boot-looping every ~2.6s + with `invalid header: 0xffffffff`** — the flash had NO valid image (the + MeshCore web-flash was evidently interrupted/incomplete). So the state the + user was judging was unbootable-erased-flash territory, and the USB port + was re-enumerating constantly (every esptool/pyserial attempt died with + `Errno 71` / `could not configure port` until this was understood). +- Reflashed OUR build as the full **merged image at 0x0** (clean slate: + bootloader + partitions + app, NVS/prefs wiped — appropriate here since + the MeshCore flash had already destroyed our NVS/partition state). With a + boot-looping ROM, the flash must catch an alive-window: a simple retry + loop around esptool connected first try. +- New diagnostics both healthy on the next boot: `[BOOT] xl9555 ok` (the + expander — and therefore the DISP_RST pulse — really executes on this + unit), and `[DISP] RDDPM=0xBC` (booster on, sleep-out, display-on read + back from the ST7796 itself). **User then confirmed the UI is visible: + "worked!"** +- Caveat for the next session: the working flash differs from the + still-black flash in TWO ways (DISP_RST fix was in both, but this one is + also a clean-slate NVS/prefs wipe — and the "still black" observation may + even have been made against the already-erased flash). If black ever + returns, the `[DISP]` readback line now discriminates instantly: + RDDPM=0xBC + black glass = backlight path; RDDPM=0x00/0xFF = panel + reset/SPI path. +- The `[DISP]` readback + `[BOOT] xl9555` prints are DELIBERATELY left in + until cold-power-cycle testing passes; remove the `[DISP]` one (marked + TEMPORARY in ST7796LCDDisplay.cpp) once M7's gates are done. +- Still open: repeated cold-power-cycle test (the historical worst case), + and the long-uptime repeating `[STALL] ui:gps ~1.6s` observation. + +**Final session-2 validation (user, on device):** boot logo → regular +wadamesh UI every time, drove the menus by encoder + keyboard, "nothing to +complain", no perceived lag, encoder detent feel correct (closes tracker +risk 2a — the assumed 4-transitions-per-detent is right for this part). + +One unexplained observation to keep an eye on: at one point (after a +user-side reset, before a monitored re-reset) the loop was emitting +`[STALL] ui:lvgl ~265ms` continuously — i.e. near-full-screen LVGL redraws +~4×/s in steady state. Gone after the next reset; user felt no lag while +driving the UI; which screen was up during the stream is unknown. If UI +sluggishness is ever reported, correlate the live `[STALL]` stream with the +on-screen state first — something was invalidating aggressively. (Related +open item from earlier the same day: a long-uptime state where +`[STALL] ui:gps ~1.6s` repeated every pass — that tag's span covers +everything from `updateGpsLocation()` to `uiCp("ui:verchk")` including the +TCA8418 I2C poll, so it may have been I2C timeouts from the then-unpowered +DRV2605/ES8311 clamping the bus; the full vendor rail set landed since. If +it recurs, add finer uiCp checkpoints inside that span.) + +--- + +# Session 1 (original log) + +Working notes from the first real-hardware session on the T-LoRa Pager LR1121 +board, picking up right after Milestone 6 (UITask wiring) landed on branch +`tlora-pager-port-lr1121`. Not a tracker doc — this is a blow-by-blow record +of what broke, what was tried, what actually got fixed, and what's still +unresolved, so the next session doesn't have to re-derive any of it. + +**Status at end of session: UNRESOLVED.** Screen currently shows black after +a full power cycle + reflash of the unchanged binary. Three real bugs got +found and fixed along the way (all believed solid, kept); a fourth, deeper +SPI-sharing issue is the current leading suspect and is NOT yet fixed. + +--- + +## How to pick this back up + +1. Re-read this whole file before touching anything. +2. Current hardware state: board is flashed with the build described in + "Current file state" below. Screen is black; serial boot log is clean + (reaches `[BOOT] ui ready` + one `[STALL] ui:gps ~700-900ms` line, no + crash) every time, including immediately after a genuine full power cycle. +3. First thing to try: the "Next diagnostic step" section at the bottom — + temporarily stub out `radio_init()` (just `return true;` before the real + body) and reflash, to isolate whether the radio's own SPI activity is what's + corrupting the display, independent of any boot-order/timing luck. +4. Serial monitoring recipe that actually works in this sandboxed environment + (PlatformIO's own `pio device monitor` fails here — `termios.error: + Inappropriate ioctl for device`, no real tty): use a small inline Python + `pyserial` script to open `/dev/ttyACM0` at 115200 and read for N seconds. + See any of this session's `Bash` calls for the exact snippet. +5. Flashing recipe: **the canonical, hardware-verified commands now live in + `CLAUDE.md`'s "T-LoRa Pager" section** (app-only at 0x10000 for iteration — + preserves NVS/prefs — vs merged at 0x0 for clean-slate/recovery; both + need `--no-stub --baud 115200` + explicit `--flash_freq 80m + --flash_size 16MB`, because `pio run -t upload`'s stub handshake fails on + this board's native-USB-CDC port). Session-1 history: only the merged-image + form had been discovered at this point. + +--- + +## Chronological account + +### 0. Starting point +Milestone 6 was already committed (`641700f`). User wanted to generate a +flashable image for **M5Launcher** (a phone/on-device flasher app) to do the +very first flash of wadamesh onto this physical board, then connect and start +Milestone 7 (headless hardware bring-up). + +### 1. Merged binary for M5Launcher +`merge-bin.py`'s `mergebin` PlatformIO custom target already existed in this +repo (bootloader + partition table + boot_app0 + app flattened into one image +at 0x0). Built it, copied to `out/tlora_pager_lr1121_companion_radio_touch-merged.bin`. +Confirmed with the user that the "never flash the merged image" caution in +`CLAUDE.md` is specifically about wiping NVS on a re-flash of an +already-provisioned board — irrelevant here since this was a first-ever flash. + +### 2. M5Launcher "install complete" but device didn't reboot +Expected: phone/BLE-based flashers can't toggle EN/GPIO0 like a wired +USB-serial connection can (no DTR/RTS lines to a bootloader stub), so the +chip stays in the ROM download stub until manually reset. User power-cycled +manually. This part was never actually a bug. + +### 3. BUG #1 (fixed) — stale NVS BLE-bond data crashing NimBLE init +First real serial capture showed a clean `[BOOT] board ok` immediately +followed by a boot-loop: `Guru Meditation Error: Core 1 panic'ed +(StoreProhibited)`, repeating every ~2s. `addr2line` against +`firmware.elf` resolved the fault to `TFT_eSPI::begin_tft_write()` — but that +turned out to be a RED HERRING for this specific crash; a **second**, +different crash surfaced later at the same investigation stage: +`Stack smashing protect failure!`, backtrace through +`NimBLE-Arduino/.../ble_store_nvs.c:445` (`populate_db_from_nvs`) → +`ble_store_config_init` → `NimBLEDevice::init()` → +`SerialBLEInterface::begin()` → `MultiTransportCompanionInterface::beginBle()`. + +Root cause: this board had prior firmware on it (M5Launcher, possibly earlier +test builds) that left BLE bonding records in the **NVS flash partition** +(a region the merged-image flash never touches — it only writes +bootloader/partition-table/app, 0x0–~0x2A0000). Our NimBLE build's compiled-in +bond-array size didn't match what was already stored, overflowing a +fixed-size RAM array while restoring it at boot. + +**Fix applied:** full chip erase was blocked by the auto-mode safety +classifier (irreversible-deletion guard, correctly — it would also have wiped +any M5Launcher partition still on the board). Used the **normal (non-merged) +4-component upload** instead (`pio run -t upload`), which — combined with +however PlatformIO/esptool's upload sequence handles the partition regions — +resulted in a much healthier NVS (`nvs_free_entries` went from 182/183 to +549–620 and stayed there). This specific upload attempt itself then failed +over the wire (`esptool` loader-stub handshake issue, see below), which is +what led to discovering the `--no-stub` flashing recipe. Confirmed fixed: +boot log has been clean (no stack-smash, no reboot loop) for the rest of the +session, through many subsequent reflashes. + +### 4. Flashing mechanics discovered along the way +- `pio device monitor` doesn't work in this sandboxed/non-tty environment — + use a `pyserial` script instead (see "How to pick this back up" above). +- `pio run -t upload` (normal path, uses esptool's loader **stub**) fails on + this board's native-USB-CDC port with `A fatal error occurred: No serial + data received` right after "Changing baud rate to 921600" — the stub + handshake doesn't survive the baud change over this specific CDC + connection. +- Fix: call `esptool.py` directly with `--no-stub --baud 115200` (talks to + the ROM bootloader the whole time, never hands off to the faster stub). + Slower (~30s/flash) but reliable every time this session. + +### 5. BUG #2 (fixed) — pager had no way to wake the screen once idle-dimmed +After the NVS fix, boot completed cleanly and reached the main loop +(confirmed via `[STALL] ui:gps`/`ui:lvgl` entries firing repeatedly), but the +screen would go dark after the normal idle-timeout and never come back — the +user could see it dim, but no key press or encoder turn revived it. + +Root cause: `handleHwKey()` (shared code, used by the T-Deck's keyboard too) +early-returns if the screen is off, and relies on some OTHER input path +(touch, or the T-Deck's trackball) to call `wakeScreen()`. The pager has +neither touch nor a trackball — keyboard + rotary encoder are its ONLY +inputs — so nothing in the T-Deck/Tanmatsu code paths ever called +`wakeScreen()` for it. + +**Fix applied** (`src/ui-touch/UITask.cpp`): +- `updatePagerEncoder()`: if the screen is off, any encoder movement or click + now calls `wakeScreen()` and returns immediately (swallowing that event + rather than also acting as navigation) instead of falling through to + `navPushTap()`. +- The `HAS_PAGER_KEYBOARD` drain in `UITask::loop()`: if the screen is off, + drains the whole keyboard FIFO batch (so nothing queued leaks through as + real input right after waking) and calls `wakeScreen()` once if anything + was in it, instead of calling `handleHwKey()` per key. + +Confirmed fixed on hardware: encoder/keyboard now reliably wake the display. + +### 6. BUG #3 (fixed) — TFT_eSPI ESP32-S3 SPI-port crash on first display write +With the wake fix in, the very first display write (the pre-LVGL boot +wordmark) crashed: `Guru Meditation Error ... StoreProhibited`, backtrace +through `TFT_eSPI::begin_tft_write()` → `writecommand()` → `TFT_eSPI::init()` +→ `ST7796LCDDisplay::begin()` → `main.cpp:setup()`. `EXCVADDR: 0x00000010`. + +Root cause, confirmed by reading `TFT_eSPI_ESP32_S3.h`/`.c` and the pulled +ESP-IDF `soc.h`/`spi_reg.h` headers directly: TFT_eSPI's raw register macros +(`_spi_user`, used by `SET_BUS_WRITE_MODE` etc.) compute +`SPI_USER_REG(SPI_PORT) = REG_SPI_BASE(SPI_PORT) + 0x10`, and +`REG_SPI_BASE(i) = (i>=2) ? (DR_REG_SPI2_BASE + (i-2)*0x1000) : 0` — i.e. it +needs the **real IDF host index** (2 or 3), not the Arduino-core `FSPI`/`HSPI` +enum values (0/1 on ESP32-S3). Leaving `USE_HSPI_PORT`/`USE_FSPI_PORT` +undefined makes `TFT_eSPI_ESP32_S3.h` default `SPI_PORT` to the *Arduino* +`FSPI` macro (0) — feeding that into the raw macros above yields address +`0x10`, exactly matching the crash. + +**Fix applied** (`platformio.ini`, `tlora_pager_lr1121_companion_radio_touch` +env): added `-D USE_FSPI_PORT=1`. This forces `SPI_PORT=2` (real SPI2) +consistently in both the raw macros and the `SPIClass` object TFT_eSPI +constructs internally. `USE_HSPI_PORT` (real SPI3) was deliberately NOT used +instead, because the radio's own SPI (at the time) defaulted to real SPI3 too +— see bug #4, this turned out to matter a lot more than expected. + +Confirmed fixed: boot no longer crashes at the first display write. + +### 7. BUG #4 (fixed) — this exact ST7796 panel batch needs INVON +Next: boot proceeded, but the screen showed a **white background with a +black logo** — inverted from the intended dark theme (confirmed the +centering/timing/colors of everything else were otherwise correct). + +Root cause: TFT_eSPI's generic `ST7796_Init.h` command table never sends an +inversion command (`0x20`/`0x21`) at all — it leaves the panel at its own +power-on default. Confirmed against **trail-mate's own bespoke (non-TFT_eSPI) +ST7796 driver** for this exact board — their init table explicitly sends +`0x21` (`INVON`), which is why their build never showed this. + +**Fix applied** (`src/helpers/ui/ST7796LCDDisplay.cpp`, +`ST7796LCDDisplay::begin()`): added `display.invertDisplay(true);` right +after `display.init()`. TFT_eSPI's `invertDisplay()` sends `TFT_INVON` twice +(per its own code comment, "otherwise it does not always work"). + +Confirmed fixed: colors correct on the next boot. + +### 8. Symptom: correct colors, but frozen on the plain boot mark forever +Boot logs looked perfect (`[BOOT] ui ready`, `[UI] splash dismissed` — both +temporary diagnostic prints added during this investigation, since removed — +fired exactly on schedule), but the user always saw ONLY the plain +pre-LVGL boot mark (no teal dots, no "WADA MESH"/"MESHCOMOD"/"TOUCH BETA" +text, no status bar) — a photo confirmed this directly. Pressing +keys/turning the encoder woke the *backlight* (bug #2's fix working +correctly) but never changed the image. + +Added temporary instrumentation to `lvglFlush()` (logged every flush's area ++ `isOn()`) and a temporary per-second direct `fillRect(RED)/fillRect(GREEN)` +canary in the main loop that completely bypassed LVGL. Findings: +- `lvglFlush()` WAS being called dozens of times with varied, correct-looking + coordinates covering the whole 480×222 screen (status bar, splash text + region, etc.), always reporting `isOn()==true`. +- The direct canary (bypassing LVGL entirely) ALSO never visibly changed the + screen, despite firing every second for the whole session. +- Conclusion: software believed every write succeeded; the physical glass + was not receiving ANY of them, except the very first (pre-`radio_init()`) + boot-mark paint. + +### 9. BUG #5 (fixed, but see §11 — likely incomplete) — shared SPI pins, two separate hosts +This board's radio (`P_LORA_SCLK/MISO/MOSI` = 35/33/34) and display +(`TFT_SCLK/MISO/MOSI` = 35/33/34) use the **identical physical pins** — by +design, only chip-select differs (`LORA_NSS=36` vs `TFT_CS=38`). But +`variants/lilygo_tlora_pager/target.cpp` constructed the radio's OWN, separate +`SPIClass spi;` (Arduino default ctor → real SPI3), while the display (via +`-D USE_FSPI_PORT=1`, bug #3) used TFT_eSPI's own separately-constructed +`SPIClass(FSPI)` (→ real SPI2). Two independent host peripherals wired to the +same GPIO pins is not valid bus sharing on ESP32 — the GPIO matrix's OUTPUT +routing for a pin can only reflect ONE peripheral's signal at a time, and +`radio_init()`'s own `spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI)` call +(which runs AFTER `display.begin()` in `main.cpp`) silently re-routed those +pins' matrix assignment to SPI3 — stealing them from the display's SPI2, +exactly matching every symptom in §8 (software succeeds, glass frozen after +that point). + +Compared against the T-Deck's own working pattern +(`variants/lilygo_tdeck/target.cpp`'s `tdeckSharedSPI()`, which returns +`&spi` — the SAME object instance — to whatever else needs the bus). T-Deck +never needed this for its OWN display, though, because its radio and display +are on entirely different pins there; it only shares between radio and SD. + +**Fix applied** (`variants/lilygo_tlora_pager/target.cpp`): +- `RADIO_CLASS radio = new Module(..., spi)` → `..., TFT_eSPI::getSPIinstance())`. + `TFT_eSPI::getSPIinstance()` is a `static` public accessor returning a + reference to the exact same file-static `SPIClass` object TFT_eSPI + constructed for the display (safe to capture at global-construction time + even though the object's own initialization order across translation units + is technically unspecified, because we only ever *use* it later, inside + `radio_init()`, well after all global constructors have run). +- Removed target.cpp's own `static SPIClass spi;` and the `spi.begin(...)` + call inside `radio_init()` — the shared object was already attached to + those pins by `display.begin()` (which runs earlier in `main.cpp`). +- First attempt at this fix (before finding `getSPIinstance()`) tried adding a + `ST7796LCDDisplay::reclaimSpiBus()` that called `display.init()` again after + `radio_init()` — this did **nothing**, because `TFT_eSPI::init()`'s + `spi.begin()` call is itself guarded by an internal `_booted` flag that gets + cleared after the first run; calling `init()` a second time silently skips + the exact re-attachment step needed. That dead-end method was added, then + removed once the real fix (above) was found. + +**Confirmed working, twice, on real hardware:** +1. Right after this fix (with the debug canary still in place): screen + visibly flashed red/green, alternating, exactly as the canary code + commanded. +2. After removing ALL temporary debug instrumentation (see §10) and + reflashing the clean build: user confirmed the actual Wadamesh UI painted + correctly ("yeah its showing wadamesh! :)"). + +### 10. Cleanup pass +Removed all temporary debugging code added during §8–9: +- `lvglFlush()`'s per-call `[FLUSH] #N area=... isOn=...` logging. +- The per-second direct-`fillRect` red/green canary in `UITask::loop()`. +- `splashRemove()`'s `[UI] splash dismissed` print. +- The dead-end `ST7796LCDDisplay::reclaimSpiBus()` method (declaration + + definition) and its call site in `main.cpp`. + +**Kept** (judged generally useful, matches the existing `[BOOT] ...` +milestone-line convention, cheap): `Serial.println("[BOOT] ui ready");` +right after `ui_task.begin()` in `main.cpp` — the only diagnostic addition +still present in the current diff. + +### 11. Regression: black screen again, survives a full power cycle +After the confirmed-working cleanup build, continued testing/further resets +eventually led to a black screen again. Troubleshooting so far: +- A **soft** reset (via `esptool`'s RTS-pin toggle) reliably reproduces a + clean, crash-free boot log every time (`mesh ok → wifiConfig ok → + serial_interface ok → ui ready → STALL ui:gps ~700-900ms`) — indistinguishable + from the boot that worked. +- A **genuine full power cycle** (USB cable fully unplugged ~10s, replugged) + was tried on the theory that some external rail (I2C-expander-controlled + `LORA_EN`/`GPS_EN`/`KB_EN`/`SD_EN` — see `TLoraPagerBoard.cpp`) might stay + "warm" across soft CPU-only resets but needs settling time on a true cold + boot. This made things WORSE, not better — black from the very first boot, + not even the pre-LVGL boot mark. (Note: confirmed there is no + display/backlight-specific `_EN` channel on the IO expander at all — only + `KB_RST/LORA_EN/GPS_EN/KB_EN/SD_DET/SD_PULLEN/SD_EN` — so the rail-settle + theory doesn't actually apply to the display specifically; this avenue is + likely a dead end, noted here so it isn't re-tried.) +- Reflashing the exact same, unchanged binary again did NOT fix it either — + rules out "one-off flash corruption." +- Also noted: the physical RESET button does not appear to actually reset the + board (no boot log appears when the user presses it), while + software-triggered resets (via `esptool`) do work reliably. Unconfirmed + whether this is a board wiring quirk or user expectation mismatch — worth + clarifying next session (which physical button is actually being pressed). + +**Current leading theory (unconfirmed):** TFT_eSPI's actual pixel-pushing +implementation does NOT go through the shared `SPIClass` object's own +mutex-protected `transfer()`/`beginTransaction()` path — it pokes the SPI +peripheral's hardware registers directly for speed (the same `_spi_cmd`/ +`_spi_user`/`_spi_mosi_dlen` raw-register macros from bug #3). RadioLib, using +the same shared object, DOES go through its normal mutex-protected calls. So +even with one shared C++ object (bug #5's fix), TFT_eSPI's fast path bypasses +the very locking meant to keep two devices off the bus simultaneously — if +the radio's own SPI activity (e.g. polling IRQ/status registers) lands at the +wrong instant relative to a display write, they can corrupt each other at the +hardware level. This would explain intermittent, timing-dependent +success/failure with NO code differences between runs — consistent with +everything observed in §11. **Not yet confirmed or fixed.** + +--- + +## Current file state (uncommitted, on branch `tlora-pager-port-lr1121`) + +``` + M TLORA_PAGER_PORT.md (tracker doc -- pre-existing M6 update, keep out of commits) + M TLORA_PAGER_PORT_MILESTONES.md (tracker doc -- pre-existing M6 update, keep out of commits) + M platformio.ini (+19: -D USE_FSPI_PORT=1 + comment, bug #3) + M src/helpers/ui/ST7796LCDDisplay.cpp (+11: display.invertDisplay(true), bug #4) + M src/main.cpp (+1: "[BOOT] ui ready" diagnostic print, kept from investigation) + M src/ui-touch/UITask.cpp (+39/-8: pager screen-wake fix, bug #2 -- clean, confirmed working) + M variants/lilygo_tlora_pager/target.cpp (+24/-3: shared SPIClass fix, bug #5 -- confirmed working twice, but §11 regression suggests incomplete) +``` + +All of the above (except the two tracker `.md` files, per standing +instruction) are believed-good fixes worth keeping and eventually committing +— they are NOT the cause of the current black-screen regression (the +cleaned-up build with all of them applied was directly confirmed working on +hardware before the regression in §11 appeared). Milestone 6's own commit +(`641700f`) is untouched/already landed separately. + +## Next diagnostic step (where to start next session) + +Temporarily stub `radio_init()` in `variants/lilygo_tlora_pager/target.cpp` to +just `return true;` before its real body (radio won't actually work, but +nothing will touch the shared SPI bus except the display), reflash, and see +if the display then works **reliably, every single boot, including after a +full power cycle** with zero radio activity: +- If yes, every time → confirms the radio/display SPI race in §11's theory; + next step is figuring out how to serialize them properly (e.g. wrapping + RadioLib's SPI calls or TFT_eSPI's raw pokes with an explicit shared mutex, + or checking whether RadioLib's IRQ-driven reads can be deferred out of any + window where a display flush might be in flight). +- If it's STILL intermittent with the radio silent → the theory is wrong, + and the real cause is elsewhere (worth re-examining the power-rail-timing + angle more carefully despite §11's evidence against it, or looking at + something else entirely, e.g. PSRAM/heap pressure, watchdog interaction, + or the AW9364 backlight driver's own state machine). diff --git a/TLORA_PAGER_PORT.md b/TLORA_PAGER_PORT.md index 705360c..df5e274 100644 --- a/TLORA_PAGER_PORT.md +++ b/TLORA_PAGER_PORT.md @@ -5,16 +5,65 @@ Goal: run the **full** wadamesh UI + functionality on the **LilyGo T-LoRa Pager* existing boards so a UI change ships everywhere at once. This file is the running plan/status — update it as we go. -Status: **planning — no hardware bring-up yet.** Board JSON verified -(`boards/lilygo-t-lora-pager.json`, landed). Milestone ① (variant skeleton) and -Milestone ② (LR1121 radio glue) landed: `variants/lilygo_tlora_pager/{pins_arduino.h, -partitions_tlora_pager_touch.csv, TLoraPagerBoard.h/.cpp, CustomLR1121.h, -CustomLR1121Wrapper.h, target.h/.cpp}`. Nothing references these files yet (no -platformio.ini env — that's M4), so both shipping envs build unchanged; the new -files are unverified by the compiler until then. Next = step ③ of the worklist -(ST7796 display driver). **Execution playbook: `TLORA_PAGER_PORT_MILESTONES.md`** -— the worklist below, broken into agent-executable milestones with gates and -per-file instructions. +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). Remaining hardware-comms gates (radio vs a live mesh node — +adverts/ACKs, upstream LR1121 ACK issue #1376; USB companion device-profile +frame; SD/SPIFFS storage) split out to **Milestone 7b** (deferred, needs a +second mesh node + human) so they don't block UI work. **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. 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. --- @@ -66,14 +115,14 @@ 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, RST −1, backlight 42 (AW9364 16-level stepped driver) | +| 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`) gates power rails: DRV_EN ch0, AMP_EN ch1, KB_RST ch2, LORA_EN ch3, GPS_EN ch4, NFC_EN ch5, GPS_RST ch7, KB_EN ch8, SD_DET ch10, SD_PULLEN ch11, SD_EN ch12 | +| 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** | @@ -123,6 +172,14 @@ existing boards. Notes: `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`, @@ -131,9 +188,24 @@ existing boards. Notes: 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` and the Heltec V4 already builds on TFT_eSPI, so - mirror that wiring (`USER_SETUP_LOADED` + `-D` pin set). Backlight is the AW9364 - (stepped pulse dimming), not a plain GPIO PWM — small driver, crib trail-mate. + 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 @@ -145,6 +217,30 @@ existing boards. Notes: `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). ## Worklist (ordered, each step ends build-green for ALL envs) @@ -186,27 +282,177 @@ existing boards. Notes: `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). -- [ ] ③ **Display**: `src/helpers/ui/ST7796LCDDisplay.{h,cpp}` on TFT_eSPI (`ST7796_DRIVER`, `TFT_WIDTH=222`, `TFT_HEIGHT=480`, MADCTL rotation for landscape) + AW9364 backlight helper. `DISPLAY_CLASS=ST7796LCDDisplay`. -- [ ] ④ **Env**: `[env:tlora_pager_lr1121_companion_radio_touch]` in `platformio.ini` — clone the T-Deck env, swap board/radio/display/input defines, add lib_deps: `adafruit/Adafruit TCA8418`, `lewisxhe/SensorLib`, `lewisxhe/XPowersLib`. **Compile gate: all three envs build.** -- [ ] ⑤ **Input drivers**: `src/helpers/input/PagerKeyboard.{h,cpp}` (TCA8418 INT-driven or polled; local keymap incl. shift/sym/alt — crib trail-mate's `LilyGoKeyboard` layout tables) and `PagerEncoder.{h,cpp}` (quadrature on 40/41 + press 7, ISR edge-counting like `TDeckTrackball`). Same pollable-API style; no LVGL in drivers. -- [ ] ⑥ **UITask wiring**: new cap block in `device_caps.h`; register KEYPAD indev via the Tanmatsu branch (~35706, widen its `#if` gate); drain keyboard → `handleHwKey()` + encoder → `navFifoPush` in the main loop (mirror the T-Deck drain at ~37320); add 480×222 to the `hor_res/ver_res` block (~35626, hardware MADCTL rotation — no sw_rotate needed); draw-buffer width 480 (`g_draw_buf_px`, ~1399); **222-px vertical audit**: `STATUSBAR_H`/`TABBAR_H`/`CHAT_KB_H` (~913–944) and modal/chat height helpers; on-screen keyboard suppressed (reuse the T-Deck path). -- [ ] ⑦ **Headless bring-up gate** (needs hardware): radio joins the live mesh (adverts seen both ways, ACKs verified — watch upstream LR1121 ACK issue meshcore-dev/MeshCore#1376); USB companion link passes the large device-profile frame (see HW-CDC note above); SPIFFS + SD storage OK. -- [ ] ⑧ **On-device UI pass**: 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. +- [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), Caps as a press-to-toggle case lock, Backspace + special-cased to `'\b'` — all UX choices trail-mate already field-validated, + reused rather than re-derived. 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** (deferred, needs hardware + second mesh + node) — radio joins the live mesh (adverts both ways, ACKs — watch upstream + LR1121 ACK issue meshcore-dev/MeshCore#1376); USB companion link passes the + large device-profile frame (see HW-CDC note above); SD storage. Split out of + ⑦ so it doesn't block ⑧'s UI work. Remove the temporary `[DISP]` register + readback in `ST7796LCDDisplay::begin()` once these pass. +- [~] ⑧ **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. - [ ] ⑩ (Optional, cheap) `tlora_pager_sx1262_...` env for SX1262-variant owners — same board JSON + variant, swap the four radio defines back to the T-Deck's SX1262 set. ## UI-changes inventory (what actually changes in `src/ui-touch/`) -| Area | Change | Size | -|---|---|---| -| Indev registration (~35706–35751) | Widen the Tanmatsu keypad-only branch's gate to the pager cap; do NOT register the pointer indev | small | -| Input drain (main loop ~37320) | Pager branch: `pagerKeyboardReadKey()`→`handleHwKey()`, encoder deltas→`navFifoPush(NEXT/PREV)`, press→ENTER | small | -| `handleHwKey()` (~27990) | Mostly reuse; verify nav-hotkey cluster makes sense on the pager's QWERTY layout | small | -| Resolution block (~35626) | New branch: 480×222 landscape (panel native is 222×480 portrait → MADCTL rotate, like T-Deck/V4) | small | -| Draw buffer (~1399) | Width 480 (PSRAM; ~larger stripe buffer, plenty of headroom in 8 MB) | trivial | -| Vertical budget | Audit `STATUSBAR_H`, `TABBAR_H`, `CHAT_KB_H`, modal/chat height helpers for 222 px — likely slimmer bars + no on-screen kb reclaim most of it | **the real work** | -| Focus-nav coverage | Screen-by-screen pass that every control is in `s_nav_group` (Tanmatsu already forced most of this) | medium, on-device | -| device_caps.h | New `CAP_*` block: no touch, hw keyboard, encoder, 480×222, SD, GPS | trivial | +| 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 (see risk 1h) | trivial | done | +| Keyboard backlight (`pagerKeyboardSetBacklight()`) | Driver function exists (M5) but not wired to any brightness-mode timer or Settings UI | small | deferred | Everything else (map, chat, contacts, channels, settings, companion protocol, MQTT, OTA) is resolution-agnostic or already keyed off caps. @@ -247,9 +493,76 @@ MQTT, OTA) is resolution-agnostic or already keyed off caps. 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 — deferred, not + fixed, in Milestone ④** (matches M3's shared-file-edit precedent). Now that + `-D USE_LR1121=1` is defined (not `USE_SX1262`/`USE_SX1268`), three call + sites default/apply this radio setting only for the SX126x macros even + though `CustomLR1121Wrapper` supports the identical feature: + `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). The radio still works fine + at default gain — this is a missed RX-sensitivity improvement, not a + broken build or crash. Fix (whenever picked up): add + `|| defined(USE_LR1121)` to those three conditions — safe/additive, zero + behavior change for T-Deck/Heltec V4. (A fourth, core-lib-only gate exists + in `CommonCLI.cpp`'s `radio.rxgain` companion CLI command, checking + `USE_SX1262`/`USE_SX1268`/`USE_LR1110` — deliberately left alone too, since + fixing just the CLI command without the three call sites above that + actually apply the setting would expose a command that appears to succeed + but doesn't do anything.) 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. + 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. +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.** The mount code these caps are supposed to gate (`fmSdTryMount()`, + the `#include ` block, the file manager's SD-vs-FFat backend + selection) was never actually migrated off `HAS_TDECK_GT911`/ + `HAS_TANMATSU` device-name checks onto the `CAP_SD` flag it claims to key + off. Setting `CAP_SD=1` for the pager in the first M6 pass just produced + `SD`/`CARD_NONE`/`fmSdTryMount` "not declared" errors, not real SD + support — reverted to 0. Real support needs pager-specific wiring (CS 21, + its own shared-SPI helper) *and* migrating that mount code to genuinely + branch on `CAP_SD` — unscheduled, do both together whenever picked up. +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). @@ -259,9 +572,26 @@ MQTT, OTA) is resolution-agnostic or already keyed off caps. 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. + 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.** + `pagerKeyboardSetBacklight()` (M5) works standalone but nothing calls it — + no brightness-mode auto-dim, no Settings toggle. Low priority, cosmetic; + pick up whenever the Settings screen work for this board happens. ## References @@ -285,3 +615,10 @@ MQTT, OTA) is resolution-agnostic or already keyed off caps. - 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_PORT_MILESTONES.md b/TLORA_PAGER_PORT_MILESTONES.md index 56a9fe0..82025f0 100644 --- a/TLORA_PAGER_PORT_MILESTONES.md +++ b/TLORA_PAGER_PORT_MILESTONES.md @@ -195,24 +195,47 @@ TX power (that class of bug cost the T-Deck ~16 dB once; see the **Deliverables** 1. `src/helpers/ui/ST7796LCDDisplay.{h,cpp}` — implement the core's `DisplayDriver` - interface exactly as `ST7789LCDDisplay` does (find it in the core lib after a - build: `.pio/libdeps//MeshCore/src/helpers/ui/ST7789LCDDisplay.h`). The - LVGL path in `UITask.cpp` only calls: `begin()`, `width()`, `height()`, - `startFrame()`/`endFrame()`, `setDisplayRotation(int)`, + interface. **`ST7789LCDDisplay` is NOT built on TFT_eSPI** (re-verified while + executing this milestone) — it's Adafruit_GFX/Adafruit_ST7789-based for both + Heltec V4 and T-Deck (same class/file); the Heltec V4 env's TFT_eSPI lib_dep/ + `-D` flags are vestigial, nothing else in the repo `#include`s `TFT_eSPI.h`. + Use `ST7789LCDDisplay` only for the `DisplayDriver`-satisfying shape, not for + any TFT_eSPI API calls — this is the first real TFT_eSPI consumer in the repo, + verify every method against the pinned `bodmer/TFT_eSPI @ ^2.5.43` source + directly. The LVGL path in `UITask.cpp` only calls: `begin()`, `width()`, + `height()`, `startFrame()`/`endFrame()`, `setDisplayRotation(int)`, `writePixelsRGB565(x, y, w, h, buf)` (flush at `UITask.cpp` ~2069), plus what `main.cpp:244+` uses for the boot screen. Build it on **TFT_eSPI** - (`ST7796_DRIVER`) the way the Heltec V4 wires TFT_eSPI via `USER_SETUP_LOADED` - `-D`s. Panel native is **222×480 portrait**; landscape 480×222 comes from - MADCTL rotation (`setRotation`), same approach as the existing boards. - Guard the whole TU with the pager's board macro so other envs don't compile it. + (`ST7796_DRIVER`). Panel native is **222×480 portrait**; landscape 480×222 + comes from MADCTL rotation (`setRotation`), same approach as the existing + boards. Guard the whole TU with the pager's board macro (`TLORA_PAGER`) so + other envs don't compile it. + **Critical, verified from TFT_eSPI's actual `ST7796_Rotation.h`/`ST7796_Defines.h`**: + this panel's 222px glass is narrower than the ST7796 controller's 320px GRAM + (trail-mate's 49px offsets = `(320-222)/2`). TFT_eSPI already applies the fix + automatically in `setAddrWindow()`, but only when `-D CGRAM_OFFSET=1` is set + (unlike `ST7789_Defines.h`, `ST7796_Defines.h` doesn't self-define it) — **add + this flag to M4's build flags below**, or every frame renders shifted/cropped + by 49px with no build error. A `#error` guard in `ST7796LCDDisplay.cpp` catches + the omission at M4 compile time. 2. Backlight: the AW9364 is a **stepped one-wire dimmer** (pulse-counted levels, - 16 steps), not a PWM pin — small helper (crib the pulse timing from trail-mate), - either inside the display class or as `Aw9364Backlight.{h,cpp}` next to it. - Expose the same brightness hook `ST7789LCDDisplay` exposes so the existing - Settings brightness UI keeps working. + 16 steps), not a PWM pin. Consume `lewisxhe/SensorLib`'s `AW9364LedDriver` + directly (header-only, MIT, already an M1-established dependency) rather than + hand-rolling the pulse timing — wrap it directly inside `ST7796LCDDisplay` + (no separate `Aw9364Backlight.{h,cpp}`; `ST7789LCDDisplay` turned out to have + no brightness hook at all to mirror — brightness on the other boards is a + `UITask.cpp`-owned free function doing raw LEDC PWM on `PIN_TFT_LEDA_CTL`, + which the AW9364 can't use). Expose `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**, since once M4 defines + that macro for the pager the existing LEDC-PWM code would also compile and + fight the AW9364's pulse protocol. -**Gate:** both existing envs build. (The new TU is gated off for them; it first -compiles for real in M4 — expect to iterate on it then.) +**Gate:** both existing envs build. (The new TU is gated off for them — neither +env's `build_src_filter` references `helpers/ui/*.cpp` yet — so it isn't even +parsed by either compiler today; it first compiles for real in M4 — expect to +iterate on it then.) --- @@ -238,7 +261,11 @@ and fix M1–M3 files as needed (that's in-scope here). `P_LORA_MOSI=34`. Drop the SX126X-specific `-D`s (`SX126X_*`, `USE_SX1262`); optionally add `RADIOLIB_EXCLUDE_SX126X=1`. - Display: `-D DISPLAY_CLASS=ST7796LCDDisplay`, TFT_eSPI set: - `USER_SETUP_LOADED=1`, `ST7796_DRIVER=1`, `TFT_WIDTH=222`, `TFT_HEIGHT=480`, + `USER_SETUP_LOADED=1`, `ST7796_DRIVER=1`, **`CGRAM_OFFSET=1`** (REQUIRED — + see Milestone ③: without it this panel's 222px-vs-320px GRAM mismatch + goes uncorrected and every frame renders shifted/cropped by 49px with no + build error; `ST7796LCDDisplay.cpp` has a `#error` guard that fires if + this is missing), `TFT_WIDTH=222`, `TFT_HEIGHT=480`, `TFT_MOSI=34`, `TFT_SCLK=35`, `TFT_CS=38`, `TFT_DC=37`, `TFT_RST=-1`, `TFT_MISO=33`, `TFT_BL=-1` (AW9364 owns brightness), `SPI_FREQUENCY` per trail-mate's panel clock. @@ -248,16 +275,50 @@ and fix M1–M3 files as needed (that's in-scope here). `PIN_GPS_TX=4` (swapped vs. trail-mate's raw values — see M2's deliverable ④ for why), `GPS_BAUD_RATE=38400`. - `FIRMWARE_OTA_ENV='"tlora_pager_lr1121_companion_radio_touch"'`. - - `build_src_filter`: T-Deck's list with `+<../variants/lilygo_tlora_pager/*.cpp>`. + - `build_src_filter`: T-Deck's list with `+<../variants/lilygo_tlora_pager/*.cpp>` + **and `+`** — the second line is required and easy to + miss: `ST7796LCDDisplay.cpp` lives in `src/helpers/ui/`, and neither + existing env's filter reaches that subdirectory (only `helpers/*.cpp`, + non-recursive). Added only to the pager's env, not retrofitted onto + Heltec V4/T-Deck. - `lib_deps`: T-Deck's list **plus** `bodmer/TFT_eSPI @ ^2.5.43`, - `adafruit/Adafruit TCA8418 @ ^1.0.2`, `lewisxhe/SensorLib` (pin the latest - release), and (only if M1 ended up needing it) `lewisxhe/XPowersLib`. + `adafruit/Adafruit TCA8418 @ ^1.0.2`, `lewisxhe/SensorLib @ 0.3.3` (the + exact version trail-mate's own `platformio.ini` pins — confirmed by + reading it directly, not guessed). **Also drop** + `adafruit/Adafruit ST7735 and ST7789 Library` from the cloned T-Deck + list — that's the Adafruit display backend `ST7789LCDDisplay` uses; + unneeded here since `ST7796LCDDisplay` is TFT_eSPI-only. No `XPowersLib` + (M1 left the BQ25896 charger out of scope). - Do NOT add the env to `default_envs` yet (keeps `pio run` = the two shipping boards until the port stabilizes). -2. Whatever fixes M1–M3 files need to make it link. +2. Whatever fixes M1–M3 files need to make it link. **Two were needed, both + found only by actually compiling — see `TLORA_PAGER_PORT.md` Decisions ② + and ⑧ for full detail:** + - `variants/lilygo_tlora_pager/pins_arduino.h` had to move to its own + `variants/lilygo_tlora_pager_pins/` folder (board JSON's `"variant"` + updated to match) — PlatformIO's arduino-esp32 build script + unconditionally compiles everything under + `board_build.variants_dir//` as a separate + `FrameworkArduinoVariant` library with none of our `lib_deps`, and since + this board (unlike T-Deck/Heltec) has no framework-bundled variant, our + own `variants_dir` override was colliding with our own app-glue + directory. + - `CustomLR1121Wrapper.h`'s bare quoted `#include "RadioLibWrappers.h"`/ + `"LR11x0Reset.h"` (copied from `CustomLR1110Wrapper.h`'s shape) don't + resolve outside the core lib's own directory — changed to angle-bracket + ``. + - **One small, forced addition to `src/ui-touch/UITask.cpp`** was also + unavoidable (not an M1–M3 file, but required for this milestone's own + hard gate): its display-class `#include`/`extern` block only recognized + `HAS_TANMATSU` vs. everything-else-is-`ST7789LCDDisplay` — added a single + `#elif defined(TLORA_PAGER)` arm to each of the two spots (~line 96-130), + nothing else touched. Full UITask wiring (indev, resolution, 222px + layout) remains Milestone ⑥'s job. -**Gate (hard):** all **three** envs build green. Record flash/RAM usage of the -pager build in the tracker. +**Gate (hard):** all **three** envs build green — **verified**. Flash/RAM +usage recorded in `TLORA_PAGER_PORT.md`'s status line (pager: RAM 22.6%, +Flash 66.1%, both lower than the two shipping boards since the UI isn't wired +up yet). --- @@ -267,68 +328,132 @@ pager build in the tracker. (begin/poll/read API, ring buffers, **zero LVGL inside drivers**). **Deliverables** -1. `src/helpers/input/PagerKeyboard.{h,cpp}` — TCA8418 over I²C (Adafruit lib), - INT pin 6 (prefer INT-driven drain; polled fallback fine). API mirrors - `TDeckKeyboard.h`: `pagerKeyboardBegin()`, `pagerKeyboardPoll()`, - `pagerKeyboardReadKey()` → **final ASCII/control codes** — i.e. the keymap + - shift/sym/alt state machine lives HERE, so the UI sees the same char stream the - T-Deck's C3 keyboard produces and `handleHwKey()` needs no changes. Crib the - matrix→ASCII layout tables from trail-mate's `LilyGoKeyboard` (find it under - `~/dev/trail-mate`; it's the working reference for this exact hardware). - Include keyboard backlight control (pin 46) mirroring - `tdeckKeyboardSetBacklight()` so the existing settings hook can drive it. -2. `src/helpers/input/PagerEncoder.{h,cpp}` — quadrature on A=40/B=41 with ISR - edge-counting exactly like `TDeckTrackball.cpp:27-47`, press on GPIO 7 with - held-state API. Suggested API: `pagerEncoderBegin()`, - `pagerEncoderReadDelta()` (signed detents since last call), - `pagerEncoderClickHeld()`. +1. `src/helpers/input/PagerKeyboard.{h,cpp}` — TCA8418 over I²C (Adafruit lib, + already an M4 dependency). **Note found executing this milestone**: + `TDeckKeyboard.h`'s API shape (begin/poll/readKey/setBacklight, SPSC ring, + threading-contract doc comment) is still the right thing to mirror, but + its *implementation* is not — the T-Deck's keyboard is a second MCU that + resolves ASCII itself before the I2C read, so `TDeckKeyboard.cpp` never + touches a keymap. The TCA8418 reports raw row/col events, so the keymap + + shift/sym/alt state machine has to live in `PagerKeyboard.cpp` itself, as + originally planned. Matrix→ASCII layout tables cribbed verbatim from + trail-mate's `LilyGoKeyboard` (same physical PCB) — `keymap[4][10]`/ + `symbol_map[4][10]`, Alt-as-hold-symbol-layer, Caps-as-toggle, Backspace + special-cased. **Verify the TCA8418 press/release bit polarity against the + actual TI datasheet (SCPS215E), not the Adafruit library's own header + comment** — they disagree (datasheet: bit 7 = 1 is press; the Adafruit + comment claims the opposite), and trail-mate's code matches the datasheet. + Implemented keyboard backlight as direct LEDC PWM on GPIO 46 (not + `tdeckKeyboardSetBacklight()`'s I2C-deferred-flush design — that + complexity existed specifically for a shared-bus/second-MCU concern that + doesn't apply here); **check which LEDC API this repo's pinned + Arduino-ESP32 framework version actually has** (`ledcSetup`/ + `ledcAttachPin`/`ledcWrite` by channel vs. the newer pin-based + `ledcAttach()`) before writing the call — trail-mate had to version-gate + between the two. +2. `src/helpers/input/PagerEncoder.{h,cpp}` — quadrature on A=40/B=41, press + on GPIO 7. **Note found executing this milestone**: `TDeckTrackball.cpp`'s + 4 direction pins are independent discrete pulses (no direction logic + needed — each pin already means one direction), not a true A/B quadrature + pair, so its exact ISR shape doesn't transfer to a signed-delta quadrature + decode. Use a standard Gray-code transition table instead (interrupt both + A and B on `CHANGE`, table lookup by `(prev_state<<2)|curr_state` yields + +1/-1/0 per edge) — same ISR-cheap-arithmetic-only / `noInterrupts()`- + snapshot-read shape as `TDeckTrackball.cpp`, correct decode logic for a + real quadrature signal. Convert raw transitions to detents via a + `STEPS_PER_DETENT` divisor (4 is the common EC11-style default) — + **unverified against this exact part, confirm on hardware in M7/M8**. + API: `pagerEncoderBegin()`, `pagerEncoderReadDelta()` (signed detents since + last call), `pagerEncoderClickHeld()`. 3. Both TUs gated by their cap macros (`HAS_PAGER_KEYBOARD` / `HAS_PAGER_ENCODER`); already picked up by the M4 `build_src_filter` (`+`). + Added the pin `-D`s (`KB_INT=6`, `KB_BACKLIGHT=46`, `ROTARY_A=40`, + `ROTARY_B=41`, `ROTARY_C=7`) to the pager env in `platformio.ini`, + matching the repo's explicit-flag-alongside-`.cpp`-fallback convention. -**Gate:** all three envs build. Driver headers carry a short doc comment stating -the contract (like `TDeckTrackball.h` does). +**Gate:** all three envs build — verified. Driver headers carry a short doc +comment stating the contract (like `TDeckTrackball.h` does). --- -## Milestone 6 — UITask wiring (caps, indev, resolution, 222-px layout) +## Milestone 6 — UITask wiring (caps, indev, resolution, 222-px layout) — DONE -**Objective:** the pager becomes a first-class UI target. This is the largest -milestone — work through it in the order below, keeping every change behind the -pager gate. Line anchors are beta_35-era; search for the symbols. +**Objective:** the pager becomes a first-class UI target. Landed; see +`TLORA_PAGER_PORT.md` worklist ⑥, the UI-changes inventory table, and risks +1h/1i/1j/8/9 for full detail. Summary of what actually happened, including +several corrections vs. this section's original plan: -**Deliverables** -1. `src/ui-touch/device_caps.h` — new pager block: no touch, hw keyboard, encoder, - 480×222, SD, GPS. Follow the existing cap style; wire `HAS_PAGER_KEYBOARD`/ - `HAS_PAGER_ENCODER`/`TLORA_PAGER` into the caps the UI actually branches on. -2. **Indev registration** (`UITask.cpp` ~35706–35751): extend the Tanmatsu - keypad-only branch (KEYPAD indev + `s_nav_group`, read_cb `tanmatsuKeypadRead`) - to also cover the pager cap. Do NOT register the POINTER indev for the pager. -3. **Input drain** (main loop, mirror the T-Deck drain at ~37320): pager branch — - `pagerKeyboardPoll()` + `while ((k = pagerKeyboardReadKey())) handleHwKey(k);` - encoder: `pagerEncoderReadDelta()` → `navFifoPush(LV_KEY_NEXT/PREV)` per detent, - click → ENTER via the FIFO, long-press → ESC. Respect `s_nav_ta_editing` - semantics already implemented in `handleHwKey()` (~27990) — no changes expected - there beyond verifying the nav-hotkey cluster doesn't collide with pager typing. -4. **Resolution** (~35626): new branch — pager is fixed 480×222 landscape via - hardware MADCTL rotation (`hor_res=480, ver_res=222`, no `sw_rotate`, no - portrait mode). Draw buffer (~1399): width 480. -5. **222-px vertical audit** — the real work. Budget: status bar + tab bar + - content in 222 px, no on-screen keyboard ever (reuse the T-Deck suppression, - ~1153/2691/28005). Review `STATUSBAR_H`, `TABBAR_H`, `CHAT_KB_H` (~913–944) and - the modal/chat height helpers (`tabContentW/H`, `modalAvailW/H`, - `chatScreenW/H`); introduce pager-gated slimmer values where 222 px overflows. - Screens to check compile-time/desk-level (full on-device pass is M8): Home, - chat thread + compose, contacts list, settings list, modals/toasts. -6. Boot screen: `main.cpp:263-267` paints the wordmark via `writePixelsRGB565` — - make sure the centering math handles 480×222. +1. `device_caps.h` got its `TLORA_PAGER` block as planned, but **`CAP_SD`/ + `CAP_FILESYSTEM` ended up 0, not 1** — the mount code they'd gate + (`fmSdTryMount()`, `#include `) is hardcoded to `HAS_TDECK_GT911` + specifically and was never actually migrated to be `CAP_SD`-generic, so + setting them to 1 just produced "not declared" errors, not real SD + support. `CAP_KEYBOARD`/`CAP_KEYPAD_NAV` widened as planned. +2. **Indev registration** (~35706): widened the Tanmatsu branch's gate to + `defined(HAS_TANMATSU) || defined(TLORA_PAGER)` as planned. The pager does + NOT call `bsp_input_get_queue` (that stays Tanmatsu-only, correctly kept + under its own inner `#if`). +3. **Input drain**: implemented as planned + (`pagerKeyboardPoll()`/`pagerKeyboardReadKey()`→`handleHwKey()`), plus a + new `updatePagerEncoder()` (delta→`navPushTap(NEXT/PREV)`, click→ENTER, + long-press ≥1000ms→ESC) placed in the main loop alongside the T-Deck + trackball update. **Found during implementation, not anticipated by this + plan**: `navMaybeRebuild()` — the function that actually populates the + focus group every screen — was unreachable for the pager's cap + combination (only called under the Tanmatsu/`CAP_TRACKBALL` branches); + without a fix the KEYPAD indev would have registered successfully but + never had anything to focus. Added a `#elif defined(TLORA_PAGER)` arm. + Also found: `handleHwKey()` and friends (`isDismissKey`, `tabForKey`, + `navMenubarKeysSync`) live inside several separately "paused and + reopened" `#if defined(HAS_TDECK_KEYBOARD)` regions scattered through the + file — each reopen's gate needed widening individually (traced real + nesting depth, not just grep hits), and the T-Deck-only p/q/a + dismiss-key mapping was deliberately kept under its own unwidened gate + since it would break normal QWERTY typing on the pager. +4. **Resolution**: implemented as planned — forced `LV_DISP_ROT_270`, + `hor_res=480/ver_res=222`. Draw buffer needed no separate edit (already + sized off `hor_res`). +5. **222-px vertical audit — deferred to M8, not done desk-side.** Decided + against guessing slimmer `STATUSBAR_H`/`TABBAR_H`/`CHAT_KB_H` constants + without hardware to verify against; the 480×222 branch compiles and the + boot wordmark centering is already generic, but real screen-by-screen + fit can only be judged on-device. Tracked as risk 8. +6. Boot screen centering: verified `main.cpp`'s existing math + (`(display.width()-WADAMESH_MARK_W)/2` etc.) is already generic and needs + no pager-specific change — `154×98` fits comfortably in `480×222`. -**Gate:** all three envs build; T-Deck/V4 binaries behaviorally unchanged (every -edit sits behind the pager gate — spot-check the diff for un-gated changes). -Update the tracker's UI-inventory table with what was actually touched. +Also added, not in the original plan: pager-first branches in +`applyBrightness()`/`touchScreenBacklight()` calling `display.setBrightness()` +ahead of the existing `PIN_TFT_LEDA_CTL` PWM branch — closes risk 1f (the +AW9364 needs discrete pulses, not a PWM duty cycle, so leaving that branch +unguarded would have driven the backlight IC incorrectly the moment M4's +`PIN_TFT_LEDA_CTL=42` definition made it compile for the pager too). + +**Gate:** all three envs build green. Pager: RAM 23.8% (78088/327680 B), +Flash 66.4% (2696465/4063232 B). Heltec V4/T-Deck: unchanged, byte-identical +to pre-M6 (25.3%/73.5%) — confirms every edit stayed behind the pager gate. --- -## Milestone 7 — Headless hardware bring-up (needs device + human) +## Milestone 7 — Headless hardware bring-up (needs device + human) — UI PORTION DONE + +**Outcome (2026-07-07):** checklist items 1–2 done and beyond — flash/monitor +recipes established (see `TLORA_PAGER_M7_HW_DEBUG_LOG.md`, which is the +blow-by-blow record of both hardware sessions and their six fixed bugs), boot +clean, rails up, SPIFFS mounts, and **the display/UI/keyboard/encoder are +user-verified working on the glass**: boot logo renders, full LVGL UI is +visible, keyboard navigation and rotary-encoder nav/select both drive the UI +correctly (root cause of the session-1 black screen: the ST7796's hardware +reset is XL9555 ch6 — absent from every pin table, found in LilyGoLib's own +board source; `TLoraPagerBoard::begin()` now owns the reset pulse). This is +the gate this milestone originally asked for on the UI side, and it's closed. + +Items 3 (radio gate vs live mesh) and 4 (HW-CDC device-profile frame) remain, +plus SD — **split out to Milestone 7b below** rather than blocking the UI +work in Milestone 8, since they need a second mesh node / companion-app +session that's independent of the on-screen UI pass. Temporary `[DISP]` +register-readback diagnostic in `ST7796LCDDisplay::begin()` stays until +Milestone 7b's gates pass. **Objective:** prove radio, storage, and companion link on real hardware before polishing UI. The agent prepares, flashes, and reads logs; the human handles the @@ -337,29 +462,54 @@ physical device and the second mesh node. **Checklist** 1. Flash with the **4-component chain** (`0x0/0x8000/0xe000/0x10000`) — NEVER the merged image (wipes NVS). `pio run -t upload -e tlora_pager_lr1121_companion_radio_touch` - or esptool with the four artifacts. + or esptool with the four artifacts. — **done** 2. Serial monitor (115200): clean boot, XL9555 rails up, gauge probe result, - SPIFFS mounts, SD detect (if card present), GPS NMEA flowing. -3. **Radio gate**: against a known-good node (T-Deck/V4 on the same freq/bw/sf): + SPIFFS mounts, SD detect (if card present), GPS NMEA flowing. — **done** +3. Radio gate — **moved to Milestone 7b**. +4. HW-CDC gate — **moved to Milestone 7b**. +5. RSSI/SNR/TX current/flash-RAM headroom — **moved to Milestone 7b**. + +**Gate (UI portion): met** — display, LVGL UI, keyboard nav, and encoder +nav/select confirmed working on real hardware. + +--- + +## Milestone 7b — Radio / USB companion / SD bring-up (deferred, needs device + human + second node) + +**Objective:** the three hardware-comms gates carried over from Milestone 7, +picked up once available (not blocking Milestone 8's UI work). + +**Checklist** +1. **Radio gate**: against a known-good node (T-Deck/V4 on the same freq/bw/sf): adverts seen BOTH directions; DM with ACK round-trip verified several times — this specifically probes upstream LR1121 ACK issue (meshcore-dev/MeshCore#1376, still open as of 2026-07-06). If ACKs fail, check whether upstream has since landed `CustomLR1121`/a pager target (it had not as of this writing — see TLORA_PAGER_PORT.md's Decision ② caveat) for post-issue fixes before debugging locally. -4. **HW-CDC gate**: connect the companion app over USB; the large device-profile +2. **HW-CDC gate**: connect the companion app over USB; the large device-profile frame (node name + keys) must arrive intact. If bytes drop (the Heltec V4 regression), rebuild with the board JSON's `ARDUINO_USB_MODE` overridden back to TinyUSB CDC and record the decision in the tracker. -5. Record RSSI/SNR sanity, TX current draw if measurable, and flash/RAM headroom. +3. **SD gate**: card detect (expander ch10) and mount behavior — note `CAP_SD`/ + `CAP_FILESYSTEM` are still 0 (risk 1h in `TLORA_PAGER_PORT.md`; the mount + code isn't actually `CAP_SD`-generic yet), so this is detect-only unless + that migration is picked up alongside. +4. Record RSSI/SNR sanity, TX current draw if measurable, and flash/RAM headroom. +5. Once all three gates pass, remove the temporary `[DISP]` register-readback + diagnostic in `ST7796LCDDisplay::begin()`. -**Gate:** all four checklist gates pass, results logged in the tracker. +**Gate:** all four checklist items pass, results logged in the tracker. --- -## Milestone 8 — On-device UI pass +## Milestone 8 — On-device UI pass — ACTIVE -**Objective:** every screen usable with encoder + QWERTY only. +**Objective:** every screen usable with encoder + QWERTY only. Now that the UI +is confirmed alive on hardware, this milestone also covers fixing any weird +behaviors the human finds while manually driving screens (nav gaps, layout +clipping at 222px, key-mapping oddities) — human supplies photos/repro steps, +agent finds the code path and fixes it behind the pager gate. **Checklist** (drive each screen on hardware; fix behind the pager gate): - Focus-nav coverage: every interactive control reachable in `s_nav_group` diff --git a/platformio.ini b/platformio.ini index d047f04..ab0f45e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -504,6 +504,25 @@ build_flags = ; 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 diff --git a/src/helpers/ui/ST7796LCDDisplay.cpp b/src/helpers/ui/ST7796LCDDisplay.cpp index c20db8f..c7bc9f2 100644 --- a/src/helpers/ui/ST7796LCDDisplay.cpp +++ b/src/helpers/ui/ST7796LCDDisplay.cpp @@ -29,6 +29,27 @@ ST7796_Rotation.h only applies the required 49px column/row offset when CGRAM_OF 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); @@ -48,8 +69,10 @@ void ST7796LCDDisplay::turnOn() { ST7796LCDDisplay::begin(); } void ST7796LCDDisplay::turnOff() { if (_isOn) { - setBrightness(0); - display.writecommand(TFT_DISPOFF); // TFT_RST=-1 on this board (not wired), so sleep via command, not reset pin + 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; } } diff --git a/src/main.cpp b/src/main.cpp index 9f07e7b..e668c34 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -579,6 +579,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 506c421..faf5e7f 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26898,6 +26898,20 @@ static void updateTrackball(unsigned long now) { // 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/clicking 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). + if (g_lv.task && g_lv.task->isScreenOff()) { + if (delta != 0 || held) g_lv.task->wakeScreen(); + return; + } + for (; delta > 0; delta--) navPushTap(LV_KEY_NEXT); for (; delta < 0; delta++) navPushTap(LV_KEY_PREV); @@ -26906,7 +26920,6 @@ static void updatePagerEncoder(unsigned long now) { static uint32_t s_press_start = 0; static bool s_long_fired = false; - const bool held = pagerEncoderClickHeld(); if (held && !s_was_held) { s_press_start = now; s_long_fired = false; @@ -37473,10 +37486,26 @@ void UITask::loop() { // No separate core-0 touch task to own the I2C bus either (no touch at all), // so poll and drain right here, once per tick. pagerKeyboardPoll(); - for (int kbi = 0; kbi < 12; ++kbi) { - int key = pagerKeyboardReadKey(); - if (key <= 0) break; - handleHwKey(key); + 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; + } + if (any) g_lv.task->wakeScreen(); + } else { + for (int kbi = 0; kbi < 12; ++kbi) { + int key = pagerKeyboardReadKey(); + if (key <= 0) break; + handleHwKey(key); + } } #endif #if defined(HAS_TANMATSU) diff --git a/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp b/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp index f3d8216..5474fee 100644 --- a/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp +++ b/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp @@ -15,25 +15,61 @@ void TLoraPagerBoard::begin() { // Configure user button pinMode(PIN_USER_BTN, INPUT); - if (io_expander.begin(Wire, PAGER_XL9555_ADDR)) { - // Enable the rails the rest of the app needs at boot: LoRa, GPS, keyboard - // (+ its reset line, held high in steady state), and the SD card. + // 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_KB_RST, + 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 trail-mate's bring-up order + delay(1); // stagger rail turn-on, mirrors the vendor's bring-up order } - delay(50); // let rails settle before anything downstream probes them 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 } gauge.begin(Wire); diff --git a/variants/lilygo_tlora_pager/TLoraPagerBoard.h b/variants/lilygo_tlora_pager/TLoraPagerBoard.h index 543b31d..8e4658c 100644 --- a/variants/lilygo_tlora_pager/TLoraPagerBoard.h +++ b/variants/lilygo_tlora_pager/TLoraPagerBoard.h @@ -15,18 +15,40 @@ #include #include -// XL9555 expander channels gating pager power rails (see pins_arduino.h and -// the hardware table in TLORA_PAGER_PORT.md). +// 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 diff --git a/variants/lilygo_tlora_pager/target.cpp b/variants/lilygo_tlora_pager/target.cpp index db6f6c4..2d3571a 100644 --- a/variants/lilygo_tlora_pager/target.cpp +++ b/variants/lilygo_tlora_pager/target.cpp @@ -4,8 +4,21 @@ TLoraPagerBoard board; -static SPIClass spi; -RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +// 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); @@ -35,7 +48,12 @@ bool radio_init() { // 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. - spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + // 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. #ifdef LORA_CR uint8_t cr = LORA_CR; From d4ca18335e401acb1357b87beb7ca84a2167e6ab Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 10:10:00 -0600 Subject: [PATCH 09/48] pager: keep home TX/RX chart clear of the right-hand button column makeHome()'s chart_w gate only widened the chart's cleared strip for HAS_TDECK_GT911/HAS_TANMATSU, so the pager (landscape, same right-hand Advert/Terminal/Apps/Control column) fell into the full-width else branch and the chart border/Sig chip ran under the buttons. Add TLORA_PAGER to the gate, matching the other landscape boards. User-verified fixed on hardware. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index faf5e7f..4dd7634 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -18752,9 +18752,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) - // Landscape (T-Deck / Tanmatsu): 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) + // Landscape (T-Deck / Tanmatsu / pager): 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; From 230b0936bc5142ddba75377609a512a8332a4637 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 10:25:10 -0600 Subject: [PATCH 10/48] pager: fix terminal Enter-to-submit and always-on rotary focus highlight Two related non-touch navigation bugs on the pager's Terminal screen: - handleHwKey()'s ENTER handling only routed to terminalSubmit() under HAS_TDECK_GT911, so physical-keyboard Enter fell through to hideKb() instead of running the typed command. Widened to TLORA_PAGER. - s_nav_show (whether the amber keypad-focus ring is painted) only defaulted true for HAS_TANMATSU ("keyboard-only device: focus highlight is always visible"); every other board, including the pager, defaulted false and only flips true inside CAP_TRACKBALL-gated code, which never runs on the pager (no trackball). Since the pager also has no touch, rotary-driven focus was moving but never rendered, so the terminal's send button looked unselectable. Widened to TLORA_PAGER, matching the Tanmatsu's rationale exactly. User-verified fixed on hardware. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 4dd7634..ccf1b70 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2199,7 +2199,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) @@ -28227,7 +28227,7 @@ static void handleHwKey(int key) { // 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) { From f9c3db99c20c3b85228ccd6c9f9bd7e92cb0fc7d Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 10:34:59 -0600 Subject: [PATCH 11/48] ui: scope keypad/trackball nav to the frontmost top-layer popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navMaybeRebuild() re-roots keyboard/rotary focus onto lv_layer_top() as soon as ANY child is visible there, but collected the WHOLE top layer. Fullscreen views that spawn their own popups (Terminal's command picker, file manager prompts/action sheets) add those popups as separate lv_layer_top() siblings rather than nesting them in the view, so with two visible top-layer children at once, nav walked the hidden view underneath before ever reaching the popup on top of it (reported: opening the terminal's command picker left rotary focus on the terminal behind it). LVGL has no z-index — child order is stacking order — so the last visible, non-NAV_SKIP child is the one actually on top. Added navTopFrontmostChild() and root nav on it instead of the whole top layer. Single-popup screens are unaffected (same one child either way). Not board-gated: this is a general nav-focus correctness fix that benefits any board using keypad/trackball navigation, not pager-specific. User-verified fixed on hardware. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index ccf1b70..7c1f78b 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -3114,6 +3114,24 @@ static bool navTopHasVisibleChild(lv_obj_t* top) { return false; } +// 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) @@ -3161,7 +3179,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) { From 53251c33e076fb03da8beac2bdfbfe87be1d1fa4 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 10:45:17 -0600 Subject: [PATCH 12/48] pager: reset idle timer on keyboard and encoder activity Neither input path called noteUserInput() for the common no-field-focused case, so the idle timeout kept counting down during active use: - handleHwKey()'s no-textarea branch only calls noteUserInput() when dismissing a popup or matching a tab hotkey. tabForKey() no longer maps any letters (tab jumps moved to the trackball-only programmable hotkeys) and isDismissKey() always returns false for the pager's full QWERTY, so with no field focused, every keypress silently no-op'd here. - updatePagerEncoder() only called noteUserInput() to wake an already-off screen; turning or clicking the encoder while the screen was ON never reset the timer. This board has no touch and no trackball to fall back on for idle-reset, so both gaps were fatal: the screen dimmed after ~30s of real use. User-verified fixed on hardware. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 7c1f78b..fcfcfcc 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26930,6 +26930,11 @@ static void updatePagerEncoder(unsigned long now) { if (delta != 0 || held) 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(); for (; delta > 0; delta--) navPushTap(LV_KEY_NEXT); for (; delta < 0; delta++) navPushTap(LV_KEY_PREV); @@ -28087,6 +28092,17 @@ static void handleHwKey(int key) { if (g_lv.task && g_lv.task->isManualLock()) { g_lv.task->lockscreenReveal(); return; } // 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(); +#endif #if CAP_TRACKBALL // Remapping a tab hotkey (Settings → Keyboard): capture the next key press. if (s_navkey_capture >= 0) { navKeyCaptureApply(key); return; } From fe4ed029f5884a3322ebad7ccbf29e9af04ba28c Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 11:12:18 -0600 Subject: [PATCH 13/48] pager: make rotary long-press a real universal back gesture Two gaps left "hold to go back" not actually working anywhere but inside a popup: - The popup registry (anyPopupOpen()/hwKeyDismissTopPopup(), which drives the actual dismiss) only recognized HAS_TDECK_GT911/HAS_TANMATSU, so it never knew how to close the pager's own Terminal fullscreen view or its command picker -- the encoder's long-press was sending a raw Esc key that nothing handled. Widened to TLORA_PAGER. - Even with that fixed, a BARE main tab (Chats, Contacts, Map, Settings) is neither a popup nor an open chat/channel detail, so the back cascade fell through to a no-op Esc. T-Deck/Tanmatsu don't hit this because they have separate dedicated Home hotkeys; the pager has none (no programmable keys, and the bottom tab bar is deliberately not a nav-group target). Added a third rung: fall back to the Home tab before plain Esc, matching every other "close" path in this app (the Terminal/Files Home button, the fullscreen-view popup closer) already landing on Home rather than a real back-stack. User-verified fixed on hardware. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index fcfcfcc..aa4b109 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26948,7 +26948,19 @@ static void updatePagerEncoder(unsigned long now) { s_press_start = now; s_long_fired = false; } else if (held && !s_long_fired && (now - s_press_start) >= kLongPressMs) { - navPushTap(LV_KEY_ESC); + // "Back", extending the T-Deck/Tanmatsu back-key ladder: a popup/sheet on top + // closes first, then an open chat/channel detail. Unlike those boards, the + // pager has no dedicated Home hotkey (T-Deck/Tanmatsu reach Home via their + // own programmable/coloured keys, not this "back" key) and the bottom tab + // bar is deliberately not a nav-group target, so a BARE main tab (Chats, + // Contacts, Map, Settings, …) had no way back to Home at all. Fall back to + // Home before plain ESC — matches every other "close" path in this app + // (the Terminal/Files Home button, the fullscreen-view popup closer) already + // landing on Home rather than a real back-stack. + 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); s_long_fired = true; } else if (!held && s_was_held && !s_long_fired) { navPushTap(LV_KEY_ENTER); // released before the long-press threshold -> short click @@ -37841,7 +37853,7 @@ 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 }, From f61b8fbbcfe090d333ece810fa5d8b6abec5f063 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 11:21:57 -0600 Subject: [PATCH 14/48] pager: Alt+rotary jumps between main tabs The bottom tab bar (Chats/Contacts/Home/Map/Settings) is deliberately not a nav-group focus target, same as T-Deck/Tanmatsu -- but those boards reach it via their own dedicated hotkeys (programmable keys / coloured shape keys), which the pager has none of. Plain rotary turning could therefore only ever move focus within the current screen; the tab bar icons were unreachable from Home (or anywhere else). Exposed pagerKeyboardAltHeld() (raw modifier state already tracked by pagerKeyboardPoll() to drive the symbol layer, just never surfaced before) and used it in updatePagerEncoder(): holding Alt (the bottom-left orange key) while turning the encoder now calls navSwitchTab(+-1) per detent instead of the normal focus-move NEXT/PREV, cycling through the 5 main tabs directly. Alt alone still types nothing, so this doesn't take anything away from the keyboard. User-verified fixed on hardware. Signed-off-by: Tesso M Costa --- src/helpers/input/PagerKeyboard.cpp | 2 ++ src/helpers/input/PagerKeyboard.h | 7 +++++++ src/ui-touch/UITask.cpp | 17 +++++++++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index 35cf042..8ee5159 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -122,4 +122,6 @@ void pagerKeyboardSetBacklight(uint8_t level) { ledcWrite(kKbBacklightPwmChannel, level); } +bool pagerKeyboardAltHeld() { return s_alt; } + #endif diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index d0a4b92..059e916 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -37,4 +37,11 @@ int pagerKeyboardReadKey(); * 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(); + #endif diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index aa4b109..5a35296 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26936,8 +26936,21 @@ static void updatePagerEncoder(unsigned long now) { // matching TLORA_PAGER fix) and the screen dimmed mid-use. if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput(); - for (; delta > 0; delta--) navPushTap(LV_KEY_NEXT); - for (; delta < 0; delta++) navPushTap(LV_KEY_PREV); + if (pagerKeyboardAltHeld()) { + // 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. + for (; delta > 0; delta--) navSwitchTab(+1); + for (; delta < 0; delta++) navSwitchTab(-1); + } 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; From 05b24f01adb06cacc76ba6068985e0c11ded2c39 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 14:16:21 -0600 Subject: [PATCH 15/48] pager: fix encoder wake-selects-Skip, and confirm-before-advance on region pick Two related first-boot-wizard bugs found on hardware: - updatePagerEncoder() woke the screen on either a turn OR a bare click. Waking via click let the SAME press-release cycle fall straight into the short-click logic further down as an ordinary click on whatever was already focused -- concretely, it selected "Skip" on the wizard's welcome screen the instant the screen lit up. Only a turn wakes the screen now; BOOT is unchanged and still wakes via its own path. - The region step's 20-preset list auto-jumped focus to Next on the FIRST click, before the row's colour change was even visible -- no way to confirm the right region got picked before focus moved off the list. Changed to two-step: the first click on a row selects/recolours it and focus stays put; a second click on the row that's ALREADY selected is treated as the explicit confirm and jumps focus to Next. Both gated CAP_KEYPAD_NAV (Tanmatsu + pager); T-Deck/Heltec V4 touch flows are unaffected either way (moving the internal group focus is invisible without active keypad-nav). User-verified fixed on hardware (fresh first-boot flash, region select, Wi-Fi connect, and map tile auto-download all working). Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5a35296..debe399 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -3743,6 +3743,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) @@ -26921,13 +26922,19 @@ static void updatePagerEncoder(unsigned long now) { // 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/clicking it just wakes -- it's swallowed here - // rather than also acting as nav, matching the trackball's edge-triggered + // 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()) { - if (delta != 0 || held) g_lv.task->wakeScreen(); + if (delta != 0) g_lv.task->wakeScreen(); return; } // Screen already on: turning/clicking the encoder is real activity too, same as a @@ -31644,6 +31651,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); @@ -31725,6 +31733,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); @@ -31733,6 +31744,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() { @@ -31769,6 +31795,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; @@ -31822,7 +31849,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); From b3c638842e75f7904476b7f47ca53434c851ce08 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 15:50:07 -0600 Subject: [PATCH 16/48] pager: fix LR1121 TX power default and RX-boosted-gain gating Boot TX power was left at a conservative 10dBm (Heltec V4's pattern) with no way to raise it, unlike the T-Deck which boots straight at the chip's real 22dBm ceiling. On hardware this was silently degrading outbound range: adverts/DMs sent from the pager were unreliable while inbound reception was fine, since the far node's own TX power was unaffected. 22dBm is confirmed as this chip's actual sub-GHz HP-PA ceiling via RadioLib's LR1120::checkOutputPower() (LR1121 inherits it) and matches trail-mate's own working config for this board. Also widen the three RX-boosted-gain gates in MyMesh.cpp/DataStore.cpp that only checked USE_SX1262/USE_SX1268 to include USE_LR1121 -- CustomLR1121Wrapper already implements setRxBoostedGainMode/ getRxBoostedGainMode, so the setting was silently a no-op for this radio. Signed-off-by: Tesso M Costa --- platformio.ini | 14 +++++++++++--- src/DataStore.cpp | 2 +- src/MyMesh.cpp | 6 +++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/platformio.ini b/platformio.ini index ab0f45e..372d704 100644 --- a/platformio.ini +++ b/platformio.ini @@ -426,9 +426,17 @@ build_flags = -D USE_LR1121=1 -D RADIO_CLASS=CustomLR1121 -D WRAPPER_CLASS=CustomLR1121Wrapper - ; Conservative boot default (matches Heltec V4's default and trail-mate's own - ; fallback) -- real RF power tuning happens on hardware in a later milestone. - -D LORA_TX_POWER=10 + ; 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 diff --git a/src/DataStore.cpp b/src/DataStore.cpp index a3ee0b0..cbf8307 100644 --- a/src/DataStore.cpp +++ b/src/DataStore.cpp @@ -261,7 +261,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 a9e398d..c586d5d 100644 --- a/src/MyMesh.cpp +++ b/src/MyMesh.cpp @@ -2537,7 +2537,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 @@ -2553,7 +2553,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", @@ -3352,7 +3352,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(); From df7f9bc2abc32ef2fffa88130f8d9db3ea4d0c83 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 16:52:44 -0600 Subject: [PATCH 17/48] pager: fix encoder direction, and Alt+turn free-scroll for nested screens Encoder direction was backwards on hardware (confirmed by user testing): kQuadTable's CW/CCW sign didn't match this physical part's wiring, so turning the way you'd expect to move forward/down moved focus and scroll backward/up instead. Negated once in pagerEncoderReadDelta() so every consumer (nav NEXT/PREV, Alt+turn tab-switch, Alt+turn page scroll) inherits the corrected sense from one place. Also scope the pager's Alt+encoder gesture: it used to always jump between main tabs, even from inside a settings sheet or chat, silently abandoning it. Now Alt+turn only tab-switches at the main-tab level (navOnMainPage()); inside a nested scrollable screen it instead free-scrolls the page like a touch drag (navScrollFocused()'s ANIMATED lv_obj_scroll_by, unlike navFocusCb's one-shot LV_ANIM_OFF scroll_to_view, reliably repaints on this board), then snaps focus to whatever's now nearest the top of the visible viewport (navRefocusFirstVisible()) so plain NEXT/PREV resumes from a visible spot instead of a focus stuck off-glass. Signed-off-by: Tesso M Costa --- src/helpers/input/PagerEncoder.cpp | 7 ++- src/ui-touch/UITask.cpp | 72 +++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/helpers/input/PagerEncoder.cpp b/src/helpers/input/PagerEncoder.cpp index deaf4fc..801727f 100644 --- a/src/helpers/input/PagerEncoder.cpp +++ b/src/helpers/input/PagerEncoder.cpp @@ -61,7 +61,12 @@ int pagerEncoderReadDelta() { int32_t raw = s_raw_delta; s_raw_delta = raw % PAGER_ENCODER_STEPS_PER_DETENT; // keep the partial-detent remainder interrupts(); - return (int)(raw / PAGER_ENCODER_STEPS_PER_DETENT); + // 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() { diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index debe399..f645106 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2589,22 +2589,51 @@ 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() { @@ -3114,6 +3143,17 @@ 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 @@ -26943,7 +26983,7 @@ static void updatePagerEncoder(unsigned long now) { // matching TLORA_PAGER fix) and the screen dimmed mid-use. if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput(); - if (pagerKeyboardAltHeld()) { + 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/ @@ -26951,9 +26991,31 @@ static void updatePagerEncoder(unsigned long now) { // 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. + // 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); From f18929b312ed32d3d091880e9be9b50f6009034a Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 17:01:06 -0600 Subject: [PATCH 18/48] stop tracking pager port trackers, matching CLAUDE.md's convention TLORA_PAGER_PORT.md and TLORA_PAGER_PORT_MILESTONES.md are working hardware bring-up notes, not published project docs -- same treatment CLAUDE.md/.claude/ already get in this .gitignore. Files stay on disk locally, just out of version control going forward. Signed-off-by: Tesso M Costa --- .gitignore | 2 + TLORA_PAGER_PORT.md | 624 --------------------------------- TLORA_PAGER_PORT_MILESTONES.md | 563 ----------------------------- 3 files changed, 2 insertions(+), 1187 deletions(-) delete mode 100644 TLORA_PAGER_PORT.md delete mode 100644 TLORA_PAGER_PORT_MILESTONES.md diff --git a/.gitignore b/.gitignore index 908dca8..c387488 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/ +TLORA_PAGER_PORT.md +TLORA_PAGER_PORT_MILESTONES.md # Custom timestamped build outputs /builds/ diff --git a/TLORA_PAGER_PORT.md b/TLORA_PAGER_PORT.md deleted file mode 100644 index df5e274..0000000 --- a/TLORA_PAGER_PORT.md +++ /dev/null @@ -1,624 +0,0 @@ -# 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). Remaining hardware-comms gates (radio vs a live mesh node — -adverts/ACKs, upstream LR1121 ACK issue #1376; USB companion device-profile -frame; SD/SPIFFS storage) split out to **Milestone 7b** (deferred, needs a -second mesh node + human) so they don't block UI work. **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. 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 (I2S 10/11/17/18/45) driving an **NS4150B** 3 W Class-D amp (enabled via expander AMP_EN ch1) | -| 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). - -## 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), Caps as a press-to-toggle case lock, Backspace - special-cased to `'\b'` — all UX choices trail-mate already field-validated, - reused rather than re-derived. 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** (deferred, needs hardware + second mesh - node) — radio joins the live mesh (adverts both ways, ACKs — watch upstream - LR1121 ACK issue meshcore-dev/MeshCore#1376); USB companion link passes the - large device-profile frame (see HW-CDC note above); SD storage. Split out of - ⑦ so it doesn't block ⑧'s UI work. Remove the temporary `[DISP]` register - readback in `ST7796LCDDisplay::begin()` once these pass. -- [~] ⑧ **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. -- [ ] ⑩ (Optional, cheap) `tlora_pager_sx1262_...` env for SX1262-variant owners — same board JSON + variant, swap the four radio defines back to the T-Deck's SX1262 set. - -## 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 (see risk 1h) | trivial | done | -| Keyboard backlight (`pagerKeyboardSetBacklight()`) | Driver function exists (M5) but not wired to any brightness-mode timer or Settings UI | small | deferred | - -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 — deferred, not - fixed, in Milestone ④** (matches M3's shared-file-edit precedent). Now that - `-D USE_LR1121=1` is defined (not `USE_SX1262`/`USE_SX1268`), three call - sites default/apply this radio setting only for the SX126x macros even - though `CustomLR1121Wrapper` supports the identical feature: - `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). The radio still works fine - at default gain — this is a missed RX-sensitivity improvement, not a - broken build or crash. Fix (whenever picked up): add - `|| defined(USE_LR1121)` to those three conditions — safe/additive, zero - behavior change for T-Deck/Heltec V4. (A fourth, core-lib-only gate exists - in `CommonCLI.cpp`'s `radio.rxgain` companion CLI command, checking - `USE_SX1262`/`USE_SX1268`/`USE_LR1110` — deliberately left alone too, since - fixing just the CLI command without the three call sites above that - actually apply the setting would expose a command that appears to succeed - but doesn't do anything.) -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. -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.** The mount code these caps are supposed to gate (`fmSdTryMount()`, - the `#include ` block, the file manager's SD-vs-FFat backend - selection) was never actually migrated off `HAS_TDECK_GT911`/ - `HAS_TANMATSU` device-name checks onto the `CAP_SD` flag it claims to key - off. Setting `CAP_SD=1` for the pager in the first M6 pass just produced - `SD`/`CARD_NONE`/`fmSdTryMount` "not declared" errors, not real SD - support — reverted to 0. Real support needs pager-specific wiring (CS 21, - its own shared-SPI helper) *and* migrating that mount code to genuinely - branch on `CAP_SD` — unscheduled, do both together whenever picked up. -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.** - `pagerKeyboardSetBacklight()` (M5) works standalone but nothing calls it — - no brightness-mode auto-dim, no Settings toggle. Low priority, cosmetic; - pick up whenever the Settings screen work for this board happens. - -## 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_PORT_MILESTONES.md b/TLORA_PAGER_PORT_MILESTONES.md deleted file mode 100644 index 82025f0..0000000 --- a/TLORA_PAGER_PORT_MILESTONES.md +++ /dev/null @@ -1,563 +0,0 @@ -# T-LoRa Pager port — implementation milestones (agent playbook) - -This file turns `TLORA_PAGER_PORT.md` (the *why* + research) into an ordered set of -**self-contained milestones for a Claude Sonnet agent to execute one at a time**. -The operator says "execute Milestone N"; the agent implements exactly that phase, -verifies its gate, updates the trackers, and stops. - ---- - -## Operator instructions - -Prompt template per phase: - -> Read `CLAUDE.md`, `TLORA_PAGER_PORT.md`, and `TLORA_PAGER_PORT_MILESTONES.md`, -> then execute **Milestone N**. Do not start work from any other milestone. - -- Milestones are strictly ordered; each assumes the previous ones are merged. -- M1–M6 are pure software (no hardware needed). M7–M8 need the device on a desk - and a human in the loop. M9–M10 are follow-ups. -- One milestone = one focused commit/PR (repo rule: one topic per PR). - -## Global rules for the executing agent (apply to EVERY milestone) - -1. **Read first**: `CLAUDE.md` (repo guide), `TLORA_PAGER_PORT.md` (hardware facts, - pin map, decisions — treat it as the source of truth for pins/defines), - `CONTRIBUTING.md`. -2. **Never regress the shipping boards.** After your changes, BOTH existing envs - must build: - ```bash - pio run -e heltec_v4_tft_companion_radio_usb_tcp_touch - pio run -e LilyGo_TDeck_companion_radio_touch - ``` - From M4 onward, the pager env must build too: - ```bash - pio run -e tlora_pager_lr1121_companion_radio_touch - ``` -3. **No refactors — and match this repo's design patterns exactly.** Copy the - existing shapes (T-Deck variant, Tanmatsu UI branch, `TDeckKeyboard`-style - drivers). Do not introduce abstraction layers, do not reorganize existing - files, do not "clean up" unrelated code. Concretely, the conventions to mirror: - - **Naming**: board classes are `Board` (`TDeckBoard` → `TLoraPagerBoard`); - variant entry points are `target.{h,cpp}`; input-driver free functions carry a - board prefix (`tdeckKeyboardBegin/Poll/ReadKey` → `pagerKeyboardBegin/Poll/ReadKey`); - UI statics are `s_*`, LVGL globals hang off `g_lv`. - - **Comments explain WHY, with the hardware constraint or war story** — see the - HW-CDC note in `platformio.ini`, the PSRAM rationale atop `main.cpp`'s - `s_si_mem`, the crash-safe note on `DataStore::savePrefs`. New code documents - non-obvious constraints the same way; it never narrates what the next line does. - - **Header doc-comments state the driver contract** (who polls, which core, - ISR-safety) — model on `TDeckKeyboard.h` / `TDeckTrackball.h`. - - **Memory discipline**: internal DRAM is the scarce pool (Wi-Fi+BLE coexistence - needs ~50 KB free). Big new buffers/objects (keymap tables, frame buffers) go - to PSRAM the way `main.cpp` places `the_mesh` and the transport object. - - **Includes**: wadamesh's own copies of files that also exist in the core lib - use **quoted** includes (the `MC_VENDORED_TOUCH_APP` pattern — see the comment - at `main.cpp:15-17`); core-lib headers use angle brackets. - - **`platformio.ini` env blocks keep the same structure**: grouped `-D` sections - with `; --- section ---` banner comments, same ordering as the T-Deck env, and - inline why-comments on any value that differs from the sibling envs. - - **Feature gating**: board/capability `#if`s, never runtime flags, for anything - board-specific — matching how `HAS_TDECK_GT911`/`HAS_TANMATSU`/`HAS_EXPANSION_KIT` - are used today. -4. **Licensing**: new files you author get `// SPDX-License-Identifier: GPL-3.0-or-later`. - Files cribbed from upstream MeshCore keep their original **MIT** header verbatim — - never relicense them. -5. **No Claude/AI attribution** in commit messages or PR text. Sign-off per DCO is - the human's job; just don't add `Co-Authored-By: Claude` or similar. -6. **Pager-specific behavior must be gated** (new `#if` on the pager's board/cap - macros) so the other boards' binaries are behaviorally unchanged. -7. **Reference material** (read, don't copy blindly): - - Local working pager port: `~/dev/trail-mate/` — - `variants/lilygo_tlora_pager/pins_arduino.h` (pin map), - `boards/tlora_pager/src/tlora_pager_board.cpp` (`initLoRa()`, power rails, SD), - `boards/tlora_pager/include/boards/tlora_pager/tlora_pager_board.h`. - - Upstream MeshCore (MIT crib source for radio wrappers + a second opinion on - everything): `https://github.com/meshcore-dev/MeshCore`, dirs - `variants/lilygo_tlora_pager/` and `src/helpers/radiolib/`. Fetch raw files - (shallow clone or raw.githubusercontent) — do NOT add it as a dependency. - - In-repo templates: `variants/lilygo_tdeck/*`, `variants/heltec_v4/*`, - `src/helpers/input/TDeck*`, the Tanmatsu branches inside `src/ui-touch/UITask.cpp`. -8. **When done**: tick the matching worklist box + refresh the `Status:` line in - `TLORA_PAGER_PORT.md`, note any deviation/discovery there (it's the living - tracker), and report build results honestly (paste failing output if red). -9. Line numbers cited below (e.g. `UITask.cpp ~35706`) were measured at beta_35 — - treat them as anchors, re-locate by searching the named symbols. - ---- - -## Milestone 1 — Variant skeleton (board class, pins, partitions) - -**Objective:** create `variants/lilygo_tlora_pager/` with everything except radio -and display, modeled on `variants/lilygo_tdeck/`. - -**Deliverables** -1. `variants/lilygo_tlora_pager/pins_arduino.h` — write our own (do NOT copy - trail-mate's: it brands `USB_PRODUCT "TRAIL MATE"`). Base it on the T-Deck's - `pins_arduino.h` shape; USB VID/PID `0x303A`/`0x82D4`, product string - `"wadamesh T-LoRa Pager"`. Pin values: see the hardware table in - `TLORA_PAGER_PORT.md` (I²C SDA 3 / SCL 2; shared SPI SCK 35 / MOSI 34 / MISO 33; - UART0 TX 43 / RX 44). -2. `variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv` — copy - `variants/lilygo_tdeck/partitions_tdeck_touch.csv` verbatim layout (2× 3.875 MB - OTA slots, tiles LittleFS 4.75 MB, spiffs 3.375 MB, coredump) — same 16 MB flash, - no reason to diverge. Update the header comment for the pager. -3. `variants/lilygo_tlora_pager/TLoraPagerBoard.h/.cpp` — `class TLoraPagerBoard : - public ESP32Board`, modeled on `TDeckBoard` but: - - `begin()`: `ESP32Board::begin()`, then bring up the **XL9555 IO expander** - (SensorLib `ExtensionIOXL9555`, I²C) and enable rails: LORA_EN(ch3), - GPS_EN(ch4), KB_EN(ch8)+KB_RST(ch2), SD rails (ch10–12) — mirror trail-mate's - power bring-up order. Handle deep-sleep wake reason like `TDeckBoard.cpp:6-34`. - - `getBattMilliVolts()`: query the **BQ27220 fuel gauge** (SensorLib - `GaugeBQ27220`) over I²C — NOT `analogReadMilliVolts` (there is no ADC divider). - Fall back to a sane constant (e.g. 3700) if the gauge probe failed, so the UI - never divides by zero. - - `getManufacturerName()`: `"LilyGo T-LoRa Pager"`. - - `enterDeepSleep()`/sleep: ext1 wake on `P_LORA_DIO_1` (GPIO 14) + BOOT (GPIO 0), - mirroring `TDeckBoard.h:26-47`. - - Keep the charger (BQ25896/XPowersLib) OUT of scope for now — note a TODO; - the gauge alone covers the UI battery display. - -**Not in this milestone:** `target.h/.cpp` (M2), display (M3), env (M4). Nothing -references the new files yet, so the two existing envs build unchanged. - -**Gate:** both existing envs build; `git status` shows only the new variant files. - ---- - -## Milestone 2 — LR1121 radio glue - -**Objective:** vendored LR1121 wrapper + the variant's `target.{h,cpp}`. - -**Deliverables** -1. `variants/lilygo_tlora_pager/CustomLR1121.h` + `CustomLR1121Wrapper.h` — - **re-check upstream `meshcore-dev/MeshCore` `src/helpers/radiolib/` first** - (as of 2026-07-06 it has no `CustomLR1121*` and no `variants/lilygo_tlora_pager/` - — issue #861 "Support for LR1121" is still open, so there's nothing to crib - yet; it may have landed by the time this milestone runs). If still absent, - author the pair by adapting the core fork's `CustomLR1110{,Wrapper}.h` - (`~/dev/MeshCore/src/helpers/radiolib/` or the pulled `core-v1.16.5` lib_dep) — - LR1110 and LR1121 share RadioLib's `LR11x0` base class, so this is a type - swap (`LR1110`→`LR1121`), not new logic. Cross-check the RF-switch table / - `setTCXO`/sync-word/preamble sequence against trail-mate's `initLoRa()` - (`~/dev/trail-mate/boards/tlora_pager/src/tlora_pager_board.cpp`), which - drives RadioLib's stock `LR1121` class directly on this exact device. They - subclass RadioLib's `LR1121` and the core's `RadioLibWrapper`, both already - on the include path; that's why they can live in the variant dir (zero - core-fork churn). -2. `variants/lilygo_tlora_pager/target.h` — mirror `variants/lilygo_tdeck/target.h`: - `RADIOLIB_STATIC_ONLY`, include the local wrapper headers (quoted includes), - externs for `board`, `radio_driver`, `radio`, `rtc_clock`, `sensors`, and the - `#ifdef DISPLAY_CLASS` display/user_btn block. -3. `variants/lilygo_tlora_pager/target.cpp` — mirror the T-Deck's: - - `RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi);` - - `WRAPPER_CLASS radio_driver(radio, board);` - - `radio_init()`: RTC + `Wire.begin(3, 2)`, SPI begin on 35/33/34, then the - **LR1121-mandatory sequence** (no `std_init` exists for it): - `radio.begin(freq, bw, sf, cr, syncword, power, preamble)` with the same - NodePrefs-driven params the other targets use, then - `setRfSwitchTable` on **DIO5/DIO6** (`STBY {L,L} / RX {L,H} / TX {H,L} / - TX_HP {H,L}`, table verbatim from trail-mate `initLoRa()` / - upstream target). Pass `tcxoVoltage=3.0f` as `begin()`'s 8th arg directly - rather than calling `setTCXO(3.0f)` again afterward — `LR11x0::modSetup()` - already applies it internally, so a second call is a no-op (trail-mate's - `initLoRa()` only needs the follow-up call because it uses the zero-arg - `begin()`, whose default `tcxoVoltage` is 1.6 V, not 3.0 V). Match sync - word / preamble / CR to what `RadioLibWrappers.cpp` uses so the pager - interoperates with the live mesh, and add `setCRC(1)` after `begin()` - (LR11x0 defaults to a 2-byte CRC; every other MeshCore radio wrapper - overrides to 1 byte for wire-protocol interop). - - `radio_new_identity()` via `RadioNoiseListener` (copy T-Deck's actual code - — not `StdRNG`, which neither T-Deck nor Heltec V4 actually use for this). -4. GPS: wire `EnvironmentSensorManager` like the T-Deck (`ENV_INCLUDE_GPS`). - **TX/RX direction, resolved**: wadamesh's own core calls - `Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX)`, and `HardwareSerial::setPins()` - takes `(rxPin, txPin)` — so `PIN_GPS_TX` supplies the ESP's RX pin and - `PIN_GPS_RX` supplies the ESP's TX pin (named from the GPS module's - perspective). trail-mate's `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 native `(rxPin, txPin)` order, so its `GPS_RX` - already IS the ESP's RX pin). **Use `PIN_GPS_RX=12` / `PIN_GPS_TX=4`** — - trail-mate's raw values swapped, not copied verbatim. MIA-M10Q baud: - confirmed 38400 from trail-mate's `Serial1.begin(38400, ...)` (same as - T-Deck Plus). - -**Gate:** both existing envs build (pager files still unreferenced). Manually -re-read the RF-switch table against BOTH references — a wrong table silently kills -TX power (that class of bug cost the T-Deck ~16 dB once; see the -`SX126X_DIO2_AS_RF_SWITCH` war story in `platformio.ini`). - ---- - -## Milestone 3 — ST7796 display driver + backlight - -**Objective:** app-side `DISPLAY_CLASS` for the 480×222 panel. - -**Deliverables** -1. `src/helpers/ui/ST7796LCDDisplay.{h,cpp}` — implement the core's `DisplayDriver` - interface. **`ST7789LCDDisplay` is NOT built on TFT_eSPI** (re-verified while - executing this milestone) — it's Adafruit_GFX/Adafruit_ST7789-based for both - Heltec V4 and T-Deck (same class/file); the Heltec V4 env's TFT_eSPI lib_dep/ - `-D` flags are vestigial, nothing else in the repo `#include`s `TFT_eSPI.h`. - Use `ST7789LCDDisplay` only for the `DisplayDriver`-satisfying shape, not for - any TFT_eSPI API calls — this is the first real TFT_eSPI consumer in the repo, - verify every method against the pinned `bodmer/TFT_eSPI @ ^2.5.43` source - directly. The LVGL path in `UITask.cpp` only calls: `begin()`, `width()`, - `height()`, `startFrame()`/`endFrame()`, `setDisplayRotation(int)`, - `writePixelsRGB565(x, y, w, h, buf)` (flush at `UITask.cpp` ~2069), plus what - `main.cpp:244+` uses for the boot screen. Build it on **TFT_eSPI** - (`ST7796_DRIVER`). Panel native is **222×480 portrait**; landscape 480×222 - comes from MADCTL rotation (`setRotation`), same approach as the existing - boards. Guard the whole TU with the pager's board macro (`TLORA_PAGER`) so - other envs don't compile it. - **Critical, verified from TFT_eSPI's actual `ST7796_Rotation.h`/`ST7796_Defines.h`**: - this panel's 222px glass is narrower than the ST7796 controller's 320px GRAM - (trail-mate's 49px offsets = `(320-222)/2`). TFT_eSPI already applies the fix - automatically in `setAddrWindow()`, but only when `-D CGRAM_OFFSET=1` is set - (unlike `ST7789_Defines.h`, `ST7796_Defines.h` doesn't self-define it) — **add - this flag to M4's build flags below**, or every frame renders shifted/cropped - by 49px with no build error. A `#error` guard in `ST7796LCDDisplay.cpp` catches - the omission at M4 compile time. -2. Backlight: the AW9364 is a **stepped one-wire dimmer** (pulse-counted levels, - 16 steps), not a PWM pin. Consume `lewisxhe/SensorLib`'s `AW9364LedDriver` - directly (header-only, MIT, already an M1-established dependency) rather than - hand-rolling the pulse timing — wrap it directly inside `ST7796LCDDisplay` - (no separate `Aw9364Backlight.{h,cpp}`; `ST7789LCDDisplay` turned out to have - no brightness hook at all to mirror — brightness on the other boards is a - `UITask.cpp`-owned free function doing raw LEDC PWM on `PIN_TFT_LEDA_CTL`, - which the AW9364 can't use). Expose `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**, since once M4 defines - that macro for the pager the existing LEDC-PWM code would also compile and - fight the AW9364's pulse protocol. - -**Gate:** both existing envs build. (The new TU is gated off for them — neither -env's `build_src_filter` references `helpers/ui/*.cpp` yet — so it isn't even -parsed by either compiler today; it first compiles for real in M4 — expect to -iterate on it then.) - ---- - -## Milestone 4 — PlatformIO env (FIRST FULL COMPILE — the integration milestone) - -**Objective:** `[env:tlora_pager_lr1121_companion_radio_touch]` builds the entire -app for the pager. This is where M1–M3 code meets the compiler; expect iteration -and fix M1–M3 files as needed (that's in-scope here). - -**Deliverables** -1. New env in `platformio.ini`, cloned from `[env:LilyGo_TDeck_companion_radio_touch]`, - with these deltas (everything not listed stays as the T-Deck has it — - MULTI_TRANSPORT_COMPANION, TCP/WS ports, BLE_PIN_CODE, MAX_CONTACTS, - LV_* flags, mbedTLS sizes, `extra_scripts`, RadioLib excludes, etc.): - - `board = lilygo-t-lora-pager`; - `board_build.partitions = variants/lilygo_tlora_pager/partitions_tlora_pager_touch.csv` - - Board: `-I variants/lilygo_tlora_pager`, `-D TLORA_PAGER=1` (new board macro), - drop `LILYGO_TDECK` + all T-Deck pins/caps (`HAS_TDECK_*`, `PIN_TB_*`, - `PIN_PERF_POWERON`). - - Radio: `-D USE_LR1121=1`, `RADIO_CLASS=CustomLR1121`, - `WRAPPER_CLASS=CustomLR1121Wrapper`, `P_LORA_NSS=36`, `P_LORA_RESET=47`, - `P_LORA_BUSY=48`, `P_LORA_DIO_1=14`, `P_LORA_SCLK=35`, `P_LORA_MISO=33`, - `P_LORA_MOSI=34`. Drop the SX126X-specific `-D`s (`SX126X_*`, `USE_SX1262`); - optionally add `RADIOLIB_EXCLUDE_SX126X=1`. - - Display: `-D DISPLAY_CLASS=ST7796LCDDisplay`, TFT_eSPI set: - `USER_SETUP_LOADED=1`, `ST7796_DRIVER=1`, **`CGRAM_OFFSET=1`** (REQUIRED — - see Milestone ③: without it this panel's 222px-vs-320px GRAM mismatch - goes uncorrected and every frame renders shifted/cropped by 49px with no - build error; `ST7796LCDDisplay.cpp` has a `#error` guard that fires if - this is missing), `TFT_WIDTH=222`, `TFT_HEIGHT=480`, - `TFT_MOSI=34`, `TFT_SCLK=35`, `TFT_CS=38`, `TFT_DC=37`, `TFT_RST=-1`, - `TFT_MISO=33`, `TFT_BL=-1` (AW9364 owns brightness), `SPI_FREQUENCY` per - trail-mate's panel clock. - - Input/caps: `PIN_USER_BTN=0`; the pager cap defines you'll consume in M5/M6 - (suggest `HAS_PAGER_KEYBOARD=1`, `HAS_PAGER_ENCODER=1`). - - GPS: `ENV_INCLUDE_GPS=1`, `ENV_SKIP_GPS_DETECT=1`, `PIN_GPS_RX=12`, - `PIN_GPS_TX=4` (swapped vs. trail-mate's raw values — see M2's deliverable - ④ for why), `GPS_BAUD_RATE=38400`. - - `FIRMWARE_OTA_ENV='"tlora_pager_lr1121_companion_radio_touch"'`. - - `build_src_filter`: T-Deck's list with `+<../variants/lilygo_tlora_pager/*.cpp>` - **and `+`** — the second line is required and easy to - miss: `ST7796LCDDisplay.cpp` lives in `src/helpers/ui/`, and neither - existing env's filter reaches that subdirectory (only `helpers/*.cpp`, - non-recursive). Added only to the pager's env, not retrofitted onto - Heltec V4/T-Deck. - - `lib_deps`: T-Deck's list **plus** `bodmer/TFT_eSPI @ ^2.5.43`, - `adafruit/Adafruit TCA8418 @ ^1.0.2`, `lewisxhe/SensorLib @ 0.3.3` (the - exact version trail-mate's own `platformio.ini` pins — confirmed by - reading it directly, not guessed). **Also drop** - `adafruit/Adafruit ST7735 and ST7789 Library` from the cloned T-Deck - list — that's the Adafruit display backend `ST7789LCDDisplay` uses; - unneeded here since `ST7796LCDDisplay` is TFT_eSPI-only. No `XPowersLib` - (M1 left the BQ25896 charger out of scope). - - Do NOT add the env to `default_envs` yet (keeps `pio run` = the two shipping - boards until the port stabilizes). -2. Whatever fixes M1–M3 files need to make it link. **Two were needed, both - found only by actually compiling — see `TLORA_PAGER_PORT.md` Decisions ② - and ⑧ for full detail:** - - `variants/lilygo_tlora_pager/pins_arduino.h` had to move to its own - `variants/lilygo_tlora_pager_pins/` folder (board JSON's `"variant"` - updated to match) — PlatformIO's arduino-esp32 build script - unconditionally compiles everything under - `board_build.variants_dir//` as a separate - `FrameworkArduinoVariant` library with none of our `lib_deps`, and since - this board (unlike T-Deck/Heltec) has no framework-bundled variant, our - own `variants_dir` override was colliding with our own app-glue - directory. - - `CustomLR1121Wrapper.h`'s bare quoted `#include "RadioLibWrappers.h"`/ - `"LR11x0Reset.h"` (copied from `CustomLR1110Wrapper.h`'s shape) don't - resolve outside the core lib's own directory — changed to angle-bracket - ``. - - **One small, forced addition to `src/ui-touch/UITask.cpp`** was also - unavoidable (not an M1–M3 file, but required for this milestone's own - hard gate): its display-class `#include`/`extern` block only recognized - `HAS_TANMATSU` vs. everything-else-is-`ST7789LCDDisplay` — added a single - `#elif defined(TLORA_PAGER)` arm to each of the two spots (~line 96-130), - nothing else touched. Full UITask wiring (indev, resolution, 222px - layout) remains Milestone ⑥'s job. - -**Gate (hard):** all **three** envs build green — **verified**. Flash/RAM -usage recorded in `TLORA_PAGER_PORT.md`'s status line (pager: RAM 22.6%, -Flash 66.1%, both lower than the two shipping boards since the UI isn't wired -up yet). - ---- - -## Milestone 5 — Input drivers (TCA8418 keyboard + rotary encoder) - -**Objective:** pollable drivers in the established `src/helpers/input/` style -(begin/poll/read API, ring buffers, **zero LVGL inside drivers**). - -**Deliverables** -1. `src/helpers/input/PagerKeyboard.{h,cpp}` — TCA8418 over I²C (Adafruit lib, - already an M4 dependency). **Note found executing this milestone**: - `TDeckKeyboard.h`'s API shape (begin/poll/readKey/setBacklight, SPSC ring, - threading-contract doc comment) is still the right thing to mirror, but - its *implementation* is not — the T-Deck's keyboard is a second MCU that - resolves ASCII itself before the I2C read, so `TDeckKeyboard.cpp` never - touches a keymap. The TCA8418 reports raw row/col events, so the keymap + - shift/sym/alt state machine has to live in `PagerKeyboard.cpp` itself, as - originally planned. Matrix→ASCII layout tables cribbed verbatim from - trail-mate's `LilyGoKeyboard` (same physical PCB) — `keymap[4][10]`/ - `symbol_map[4][10]`, Alt-as-hold-symbol-layer, Caps-as-toggle, Backspace - special-cased. **Verify the TCA8418 press/release bit polarity against the - actual TI datasheet (SCPS215E), not the Adafruit library's own header - comment** — they disagree (datasheet: bit 7 = 1 is press; the Adafruit - comment claims the opposite), and trail-mate's code matches the datasheet. - Implemented keyboard backlight as direct LEDC PWM on GPIO 46 (not - `tdeckKeyboardSetBacklight()`'s I2C-deferred-flush design — that - complexity existed specifically for a shared-bus/second-MCU concern that - doesn't apply here); **check which LEDC API this repo's pinned - Arduino-ESP32 framework version actually has** (`ledcSetup`/ - `ledcAttachPin`/`ledcWrite` by channel vs. the newer pin-based - `ledcAttach()`) before writing the call — trail-mate had to version-gate - between the two. -2. `src/helpers/input/PagerEncoder.{h,cpp}` — quadrature on A=40/B=41, press - on GPIO 7. **Note found executing this milestone**: `TDeckTrackball.cpp`'s - 4 direction pins are independent discrete pulses (no direction logic - needed — each pin already means one direction), not a true A/B quadrature - pair, so its exact ISR shape doesn't transfer to a signed-delta quadrature - decode. Use a standard Gray-code transition table instead (interrupt both - A and B on `CHANGE`, table lookup by `(prev_state<<2)|curr_state` yields - +1/-1/0 per edge) — same ISR-cheap-arithmetic-only / `noInterrupts()`- - snapshot-read shape as `TDeckTrackball.cpp`, correct decode logic for a - real quadrature signal. Convert raw transitions to detents via a - `STEPS_PER_DETENT` divisor (4 is the common EC11-style default) — - **unverified against this exact part, confirm on hardware in M7/M8**. - API: `pagerEncoderBegin()`, `pagerEncoderReadDelta()` (signed detents since - last call), `pagerEncoderClickHeld()`. -3. Both TUs gated by their cap macros (`HAS_PAGER_KEYBOARD` / `HAS_PAGER_ENCODER`); - already picked up by the M4 `build_src_filter` (`+`). - Added the pin `-D`s (`KB_INT=6`, `KB_BACKLIGHT=46`, `ROTARY_A=40`, - `ROTARY_B=41`, `ROTARY_C=7`) to the pager env in `platformio.ini`, - matching the repo's explicit-flag-alongside-`.cpp`-fallback convention. - -**Gate:** all three envs build — verified. Driver headers carry a short doc -comment stating the contract (like `TDeckTrackball.h` does). - ---- - -## Milestone 6 — UITask wiring (caps, indev, resolution, 222-px layout) — DONE - -**Objective:** the pager becomes a first-class UI target. Landed; see -`TLORA_PAGER_PORT.md` worklist ⑥, the UI-changes inventory table, and risks -1h/1i/1j/8/9 for full detail. Summary of what actually happened, including -several corrections vs. this section's original plan: - -1. `device_caps.h` got its `TLORA_PAGER` block as planned, but **`CAP_SD`/ - `CAP_FILESYSTEM` ended up 0, not 1** — the mount code they'd gate - (`fmSdTryMount()`, `#include `) is hardcoded to `HAS_TDECK_GT911` - specifically and was never actually migrated to be `CAP_SD`-generic, so - setting them to 1 just produced "not declared" errors, not real SD - support. `CAP_KEYBOARD`/`CAP_KEYPAD_NAV` widened as planned. -2. **Indev registration** (~35706): widened the Tanmatsu branch's gate to - `defined(HAS_TANMATSU) || defined(TLORA_PAGER)` as planned. The pager does - NOT call `bsp_input_get_queue` (that stays Tanmatsu-only, correctly kept - under its own inner `#if`). -3. **Input drain**: implemented as planned - (`pagerKeyboardPoll()`/`pagerKeyboardReadKey()`→`handleHwKey()`), plus a - new `updatePagerEncoder()` (delta→`navPushTap(NEXT/PREV)`, click→ENTER, - long-press ≥1000ms→ESC) placed in the main loop alongside the T-Deck - trackball update. **Found during implementation, not anticipated by this - plan**: `navMaybeRebuild()` — the function that actually populates the - focus group every screen — was unreachable for the pager's cap - combination (only called under the Tanmatsu/`CAP_TRACKBALL` branches); - without a fix the KEYPAD indev would have registered successfully but - never had anything to focus. Added a `#elif defined(TLORA_PAGER)` arm. - Also found: `handleHwKey()` and friends (`isDismissKey`, `tabForKey`, - `navMenubarKeysSync`) live inside several separately "paused and - reopened" `#if defined(HAS_TDECK_KEYBOARD)` regions scattered through the - file — each reopen's gate needed widening individually (traced real - nesting depth, not just grep hits), and the T-Deck-only p/q/a - dismiss-key mapping was deliberately kept under its own unwidened gate - since it would break normal QWERTY typing on the pager. -4. **Resolution**: implemented as planned — forced `LV_DISP_ROT_270`, - `hor_res=480/ver_res=222`. Draw buffer needed no separate edit (already - sized off `hor_res`). -5. **222-px vertical audit — deferred to M8, not done desk-side.** Decided - against guessing slimmer `STATUSBAR_H`/`TABBAR_H`/`CHAT_KB_H` constants - without hardware to verify against; the 480×222 branch compiles and the - boot wordmark centering is already generic, but real screen-by-screen - fit can only be judged on-device. Tracked as risk 8. -6. Boot screen centering: verified `main.cpp`'s existing math - (`(display.width()-WADAMESH_MARK_W)/2` etc.) is already generic and needs - no pager-specific change — `154×98` fits comfortably in `480×222`. - -Also added, not in the original plan: pager-first branches in -`applyBrightness()`/`touchScreenBacklight()` calling `display.setBrightness()` -ahead of the existing `PIN_TFT_LEDA_CTL` PWM branch — closes risk 1f (the -AW9364 needs discrete pulses, not a PWM duty cycle, so leaving that branch -unguarded would have driven the backlight IC incorrectly the moment M4's -`PIN_TFT_LEDA_CTL=42` definition made it compile for the pager too). - -**Gate:** all three envs build green. Pager: RAM 23.8% (78088/327680 B), -Flash 66.4% (2696465/4063232 B). Heltec V4/T-Deck: unchanged, byte-identical -to pre-M6 (25.3%/73.5%) — confirms every edit stayed behind the pager gate. - ---- - -## Milestone 7 — Headless hardware bring-up (needs device + human) — UI PORTION DONE - -**Outcome (2026-07-07):** checklist items 1–2 done and beyond — flash/monitor -recipes established (see `TLORA_PAGER_M7_HW_DEBUG_LOG.md`, which is the -blow-by-blow record of both hardware sessions and their six fixed bugs), boot -clean, rails up, SPIFFS mounts, and **the display/UI/keyboard/encoder are -user-verified working on the glass**: boot logo renders, full LVGL UI is -visible, keyboard navigation and rotary-encoder nav/select both drive the UI -correctly (root cause of the session-1 black screen: the ST7796's hardware -reset is XL9555 ch6 — absent from every pin table, found in LilyGoLib's own -board source; `TLoraPagerBoard::begin()` now owns the reset pulse). This is -the gate this milestone originally asked for on the UI side, and it's closed. - -Items 3 (radio gate vs live mesh) and 4 (HW-CDC device-profile frame) remain, -plus SD — **split out to Milestone 7b below** rather than blocking the UI -work in Milestone 8, since they need a second mesh node / companion-app -session that's independent of the on-screen UI pass. Temporary `[DISP]` -register-readback diagnostic in `ST7796LCDDisplay::begin()` stays until -Milestone 7b's gates pass. - -**Objective:** prove radio, storage, and companion link on real hardware before -polishing UI. The agent prepares, flashes, and reads logs; the human handles the -physical device and the second mesh node. - -**Checklist** -1. Flash with the **4-component chain** (`0x0/0x8000/0xe000/0x10000`) — NEVER the - merged image (wipes NVS). `pio run -t upload -e tlora_pager_lr1121_companion_radio_touch` - or esptool with the four artifacts. — **done** -2. Serial monitor (115200): clean boot, XL9555 rails up, gauge probe result, - SPIFFS mounts, SD detect (if card present), GPS NMEA flowing. — **done** -3. Radio gate — **moved to Milestone 7b**. -4. HW-CDC gate — **moved to Milestone 7b**. -5. RSSI/SNR/TX current/flash-RAM headroom — **moved to Milestone 7b**. - -**Gate (UI portion): met** — display, LVGL UI, keyboard nav, and encoder -nav/select confirmed working on real hardware. - ---- - -## Milestone 7b — Radio / USB companion / SD bring-up (deferred, needs device + human + second node) - -**Objective:** the three hardware-comms gates carried over from Milestone 7, -picked up once available (not blocking Milestone 8's UI work). - -**Checklist** -1. **Radio gate**: against a known-good node (T-Deck/V4 on the same freq/bw/sf): - adverts seen BOTH directions; DM with ACK round-trip verified several times — - this specifically probes upstream LR1121 ACK issue - (meshcore-dev/MeshCore#1376, still open as of 2026-07-06). If ACKs fail, - check whether upstream has since landed `CustomLR1121`/a pager target (it - had not as of this writing — see TLORA_PAGER_PORT.md's Decision ② caveat) - for post-issue fixes before debugging locally. -2. **HW-CDC gate**: connect the companion app over USB; the large device-profile - frame (node name + keys) must arrive intact. If bytes drop (the Heltec V4 - regression), rebuild with the board JSON's `ARDUINO_USB_MODE` overridden back - to TinyUSB CDC and record the decision in the tracker. -3. **SD gate**: card detect (expander ch10) and mount behavior — note `CAP_SD`/ - `CAP_FILESYSTEM` are still 0 (risk 1h in `TLORA_PAGER_PORT.md`; the mount - code isn't actually `CAP_SD`-generic yet), so this is detect-only unless - that migration is picked up alongside. -4. Record RSSI/SNR sanity, TX current draw if measurable, and flash/RAM headroom. -5. Once all three gates pass, remove the temporary `[DISP]` register-readback - diagnostic in `ST7796LCDDisplay::begin()`. - -**Gate:** all four checklist items pass, results logged in the tracker. - ---- - -## Milestone 8 — On-device UI pass — ACTIVE - -**Objective:** every screen usable with encoder + QWERTY only. Now that the UI -is confirmed alive on hardware, this milestone also covers fixing any weird -behaviors the human finds while manually driving screens (nav gaps, layout -clipping at 222px, key-mapping oddities) — human supplies photos/repro steps, -agent finds the code path and fixes it behind the pager gate. - -**Checklist** (drive each screen on hardware; fix behind the pager gate): -- Focus-nav coverage: every interactive control reachable in `s_nav_group` - (`navMaybeRebuild` per-screen collection) — tabs, lists, buttons, toggles, - text fields, modals, action sheets. -- Chat at 222 px: thread list, bubbles, compose flow (focus field → type on QWERTY - → send), per-message info sheet. -- Long lists (contacts up to 2000): encoder NEXT/PREV walk is tolerable; if not, - add pager-gated page-jump keys via `navMoveDir` (risk #5 in the tracker). -- Map: pan via nav keys/encoder, zoom keys. -- Settings: brightness (AW9364 steps), keyboard backlight, sleep/wake (encoder or - BOOT wakes), lock screen. -- Fonts/legibility at 480-wide; screenshot or photograph anything questionable for - the human to judge. - -**Gate:** human sign-off screen-by-screen; deviations logged in the tracker. - ---- - -## Milestone 9 — Release pipeline - -**Objective:** the pager ships through the existing two-channel release flow. - -**Deliverables** -1. `scripts/release.sh`: add `tlora_pager_lr1121_companion_radio_touch:wadamesh-tlora-pager` - to `ENVS`. -2. `deploy/flasher/manifest-tlora-pager.json` + flasher page entry (mirror the - T-Deck manifest; 4-component chain offsets). -3. Verify `merge-bin.py` + `scripts/build/gen-flasher-meta.py` handle the third - env (they iterate `ENVS`/manifests — check assumptions). -4. Add the env to `default_envs` now that it's shipping. -5. Do NOT touch the Mesh America catalog or LauncherHub — separate decisions. - -**Gate:** a dry-run `scripts/release.sh beta_` (without `WADAMESH_VPS` set) -stages all three boards' artifacts locally; human reviews before any real cut. - ---- - -## Milestone 10 (optional) — SX1262-variant env - -**Objective:** serve pager units sold with SX1262 instead of LR1121. - -Clone the M4 env as `tlora_pager_sx1262_companion_radio_touch`: same board JSON, -same variant dir, swap the radio block back to the T-Deck's SX1262 set -(`USE_SX1262`, `RADIO_CLASS=CustomSX1262`, `WRAPPER_CLASS=CustomSX1262Wrapper`, -same NSS/RESET/BUSY/DIO1 pins, plus the `SX126X_*` defines — check trail-mate's -SX1262 pager env for TCXO/DIO2-switch values on this board; do NOT assume the -T-Deck's). `target.cpp` needs a small `#if` around the LR1121-only init sequence. - -**Gate:** all envs build; radio gate (M7 step 3) re-run on SX1262 hardware when -available. From dc4ba96825b35aed34e19376e4da2b57f0185095 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 7 Jul 2026 17:19:45 -0600 Subject: [PATCH 19/48] stop tracking the M7 hardware debug log, same as the other pager trackers TLORA_PAGER_M7_HW_DEBUG_LOG.md is a blow-by-blow hardware bring-up log, not a published project doc -- same treatment already applied to TLORA_PAGER_PORT.md/TLORA_PAGER_PORT_MILESTONES.md and CLAUDE.md. File stays on disk locally, just out of version control going forward. Signed-off-by: Tesso M Costa --- .gitignore | 1 + TLORA_PAGER_M7_HW_DEBUG_LOG.md | 462 --------------------------------- 2 files changed, 1 insertion(+), 462 deletions(-) delete mode 100644 TLORA_PAGER_M7_HW_DEBUG_LOG.md diff --git a/.gitignore b/.gitignore index c387488..f1adc2a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ CLAUDE.md .claude/ TLORA_PAGER_PORT.md TLORA_PAGER_PORT_MILESTONES.md +TLORA_PAGER_M7_HW_DEBUG_LOG.md # Custom timestamped build outputs /builds/ diff --git a/TLORA_PAGER_M7_HW_DEBUG_LOG.md b/TLORA_PAGER_M7_HW_DEBUG_LOG.md deleted file mode 100644 index 0b5a49f..0000000 --- a/TLORA_PAGER_M7_HW_DEBUG_LOG.md +++ /dev/null @@ -1,462 +0,0 @@ -# T-LoRa Pager — M7 hardware bring-up debug log - -## Session 2 (2026-07-07) — root cause found: floating panel-reset line (XL9555 ch6) - -Picked up from session 1's black-screen regression (§11 below). Two desk-side -findings changed the picture entirely: - -**Session 1's leading theory (§11, radio/display SPI race) is DISPROVEN, from -source:** TFT_eSPI's `Processors/TFT_eSPI_ESP32_S3.h:41-43` force-defines -`SUPPORT_TRANSACTIONS` ("mandatory for ESP32 so the hal mutex is toggled") — -its raw-register fast path is still bracketed by `spi.beginTransaction()` on -the same HAL mutex RadioLib uses on the shared `SPIClass`. On top of that, -`the_mesh.loop()` and `ui_task.loop()` run on the same task and RadioLib does -no SPI from ISRs, so there is no radio/display SPI concurrency at all. Don't -re-chase this. - -**BUG #6 (fixed — believed to be §11's real root cause): the ST7796's hardware -reset line IS wired — to XL9555 channel 6 — and we left it floating.** -Session 1 concluded "no display-specific expander channel exists" from the -LilyGoLib doc's channel table and the canonical arduino-esp32 master -`pins_arduino.h` — both list channels 0-5 and 7-12 and silently omit ch6. -LilyGoLib's own board code (`src/LilyGo_LoRa_Pager.cpp`, `begin()`) defines -`EXPANDS_DISP_RST` (=6, confirmed via forks carrying the newer pins header), -drives it HIGH with the other rails, then pulses it LOW→50ms→HIGH as a real -panel hardware reset before display init. Our port never touched ch6, so the -panel's reset floated at the expander's power-on high-Z default — a floating -active-low reset is exactly the observed failure shape: intermittent black -screen with a clean boot log (a controller held in hardware reset ignores ALL -SPI, including TFT_eSPI's software-reset fallback), no code-change -boot-to-boot variance, and worst behavior on a genuine cold power cycle. -(trail-mate gets away without it because its older vendored pins header lacks -the macro, so its `#ifdef EXPANDS_DISP_RST` blocks compile out — hardware -revisions likely differ in how hard the line floats.) - -**Fixes applied this session** (all in the working tree): -- `variants/lilygo_tlora_pager/TLoraPagerBoard.{h,cpp}`: full vendor rail set - (added DISP_RST ch6, NFC_EN ch5, DRV_EN ch0, AMP_EN ch1, GPS_RST ch7, - GPIO_EN ch9 to the existing five) — vendor drives everything HIGH, and - unpowered-but-bus-connected chips (DRV2605 + ES8311 on I2C, ST25R3916 on - the shared SPI) can clamp a shared bus through their ESD diodes, so - "off because unused" wasn't safe. Then the vendor's DISP_RST pulse - (LOW→50ms→HIGH). Also parks the shared-SPI selects/resets OUTPUT-HIGH - before any bus traffic (LORA_NSS 36, LORA_RST 47, SD_CS 21, NFC_CS 39 — - LilyGoLib's `initShareSPIPins()` equivalent, which we'd skipped entirely). -- `src/helpers/ui/ST7796LCDDisplay.cpp`: `turnOff()` no longer latches the - cached brightness at 0 (a `turnOn()` would have restored 0% backlight). - Latent — UITask's pager screen-off path doesn't use turnOff() — but fixed - so it can't muddy future testing. - -**Flashing note (adds to session 1's recipe):** app-only flashing works and -is what you want for iteration (`write_flash 0x10000 firmware.bin` — leaves -bootloader/partitions/NVS untouched), but the ROM loader needs the explicit -`--flash_mode qio --flash_freq 80m --flash_size 16MB` args even for app-only -writes — without them `--no-stub` fails at erase with -`Failed to enter Flash download mode (result was 01060000)`. - -**Open observation (not yet diagnosed):** after long uptime the board emitted -`[STALL] ui:gps 1644ms` continuously (every loop pass), vs. the normal single -~835ms line right after boot. The `ui:gps` checkpoint actually covers -everything from `updateGpsLocation()` to `uiCp("ui:verchk")` — including the -TCA8418 keyboard I2C poll — so repeated 1.6s stalls smell like I2C -transactions timing out (possibly the unpowered-DRV2605/ES8311 clamping -theory above; the full vendor rail set may have fixed this too). Watch for it -after long uptime on the new build; if it recurs, add finer uiCp checkpoints -inside that span. - -**Verified this session:** build green, flashed, boot log clean -(`[BOOT] ui ready`, single 836ms gps stall). Awaiting visual confirmation on -the glass + repeated cold-power-cycle testing (the one test that -discriminates: session 1's black screens were cold-boot-worst). - -**Session 2 outcome — WORKING, user-confirmed on the glass.** Sequence of -events after the first fix flash, worth keeping straight because it created a -misleading data point: -- User reported "still black" after the first DISP_RST-fix flash, then - flashed the official MeshCore pager image and confirmed the screen works — - proving the hardware (glass/backlight/FPC) was fine all along. -- When the board came back to this machine it was **boot-looping every ~2.6s - with `invalid header: 0xffffffff`** — the flash had NO valid image (the - MeshCore web-flash was evidently interrupted/incomplete). So the state the - user was judging was unbootable-erased-flash territory, and the USB port - was re-enumerating constantly (every esptool/pyserial attempt died with - `Errno 71` / `could not configure port` until this was understood). -- Reflashed OUR build as the full **merged image at 0x0** (clean slate: - bootloader + partitions + app, NVS/prefs wiped — appropriate here since - the MeshCore flash had already destroyed our NVS/partition state). With a - boot-looping ROM, the flash must catch an alive-window: a simple retry - loop around esptool connected first try. -- New diagnostics both healthy on the next boot: `[BOOT] xl9555 ok` (the - expander — and therefore the DISP_RST pulse — really executes on this - unit), and `[DISP] RDDPM=0xBC` (booster on, sleep-out, display-on read - back from the ST7796 itself). **User then confirmed the UI is visible: - "worked!"** -- Caveat for the next session: the working flash differs from the - still-black flash in TWO ways (DISP_RST fix was in both, but this one is - also a clean-slate NVS/prefs wipe — and the "still black" observation may - even have been made against the already-erased flash). If black ever - returns, the `[DISP]` readback line now discriminates instantly: - RDDPM=0xBC + black glass = backlight path; RDDPM=0x00/0xFF = panel - reset/SPI path. -- The `[DISP]` readback + `[BOOT] xl9555` prints are DELIBERATELY left in - until cold-power-cycle testing passes; remove the `[DISP]` one (marked - TEMPORARY in ST7796LCDDisplay.cpp) once M7's gates are done. -- Still open: repeated cold-power-cycle test (the historical worst case), - and the long-uptime repeating `[STALL] ui:gps ~1.6s` observation. - -**Final session-2 validation (user, on device):** boot logo → regular -wadamesh UI every time, drove the menus by encoder + keyboard, "nothing to -complain", no perceived lag, encoder detent feel correct (closes tracker -risk 2a — the assumed 4-transitions-per-detent is right for this part). - -One unexplained observation to keep an eye on: at one point (after a -user-side reset, before a monitored re-reset) the loop was emitting -`[STALL] ui:lvgl ~265ms` continuously — i.e. near-full-screen LVGL redraws -~4×/s in steady state. Gone after the next reset; user felt no lag while -driving the UI; which screen was up during the stream is unknown. If UI -sluggishness is ever reported, correlate the live `[STALL]` stream with the -on-screen state first — something was invalidating aggressively. (Related -open item from earlier the same day: a long-uptime state where -`[STALL] ui:gps ~1.6s` repeated every pass — that tag's span covers -everything from `updateGpsLocation()` to `uiCp("ui:verchk")` including the -TCA8418 I2C poll, so it may have been I2C timeouts from the then-unpowered -DRV2605/ES8311 clamping the bus; the full vendor rail set landed since. If -it recurs, add finer uiCp checkpoints inside that span.) - ---- - -# Session 1 (original log) - -Working notes from the first real-hardware session on the T-LoRa Pager LR1121 -board, picking up right after Milestone 6 (UITask wiring) landed on branch -`tlora-pager-port-lr1121`. Not a tracker doc — this is a blow-by-blow record -of what broke, what was tried, what actually got fixed, and what's still -unresolved, so the next session doesn't have to re-derive any of it. - -**Status at end of session: UNRESOLVED.** Screen currently shows black after -a full power cycle + reflash of the unchanged binary. Three real bugs got -found and fixed along the way (all believed solid, kept); a fourth, deeper -SPI-sharing issue is the current leading suspect and is NOT yet fixed. - ---- - -## How to pick this back up - -1. Re-read this whole file before touching anything. -2. Current hardware state: board is flashed with the build described in - "Current file state" below. Screen is black; serial boot log is clean - (reaches `[BOOT] ui ready` + one `[STALL] ui:gps ~700-900ms` line, no - crash) every time, including immediately after a genuine full power cycle. -3. First thing to try: the "Next diagnostic step" section at the bottom — - temporarily stub out `radio_init()` (just `return true;` before the real - body) and reflash, to isolate whether the radio's own SPI activity is what's - corrupting the display, independent of any boot-order/timing luck. -4. Serial monitoring recipe that actually works in this sandboxed environment - (PlatformIO's own `pio device monitor` fails here — `termios.error: - Inappropriate ioctl for device`, no real tty): use a small inline Python - `pyserial` script to open `/dev/ttyACM0` at 115200 and read for N seconds. - See any of this session's `Bash` calls for the exact snippet. -5. Flashing recipe: **the canonical, hardware-verified commands now live in - `CLAUDE.md`'s "T-LoRa Pager" section** (app-only at 0x10000 for iteration — - preserves NVS/prefs — vs merged at 0x0 for clean-slate/recovery; both - need `--no-stub --baud 115200` + explicit `--flash_freq 80m - --flash_size 16MB`, because `pio run -t upload`'s stub handshake fails on - this board's native-USB-CDC port). Session-1 history: only the merged-image - form had been discovered at this point. - ---- - -## Chronological account - -### 0. Starting point -Milestone 6 was already committed (`641700f`). User wanted to generate a -flashable image for **M5Launcher** (a phone/on-device flasher app) to do the -very first flash of wadamesh onto this physical board, then connect and start -Milestone 7 (headless hardware bring-up). - -### 1. Merged binary for M5Launcher -`merge-bin.py`'s `mergebin` PlatformIO custom target already existed in this -repo (bootloader + partition table + boot_app0 + app flattened into one image -at 0x0). Built it, copied to `out/tlora_pager_lr1121_companion_radio_touch-merged.bin`. -Confirmed with the user that the "never flash the merged image" caution in -`CLAUDE.md` is specifically about wiping NVS on a re-flash of an -already-provisioned board — irrelevant here since this was a first-ever flash. - -### 2. M5Launcher "install complete" but device didn't reboot -Expected: phone/BLE-based flashers can't toggle EN/GPIO0 like a wired -USB-serial connection can (no DTR/RTS lines to a bootloader stub), so the -chip stays in the ROM download stub until manually reset. User power-cycled -manually. This part was never actually a bug. - -### 3. BUG #1 (fixed) — stale NVS BLE-bond data crashing NimBLE init -First real serial capture showed a clean `[BOOT] board ok` immediately -followed by a boot-loop: `Guru Meditation Error: Core 1 panic'ed -(StoreProhibited)`, repeating every ~2s. `addr2line` against -`firmware.elf` resolved the fault to `TFT_eSPI::begin_tft_write()` — but that -turned out to be a RED HERRING for this specific crash; a **second**, -different crash surfaced later at the same investigation stage: -`Stack smashing protect failure!`, backtrace through -`NimBLE-Arduino/.../ble_store_nvs.c:445` (`populate_db_from_nvs`) → -`ble_store_config_init` → `NimBLEDevice::init()` → -`SerialBLEInterface::begin()` → `MultiTransportCompanionInterface::beginBle()`. - -Root cause: this board had prior firmware on it (M5Launcher, possibly earlier -test builds) that left BLE bonding records in the **NVS flash partition** -(a region the merged-image flash never touches — it only writes -bootloader/partition-table/app, 0x0–~0x2A0000). Our NimBLE build's compiled-in -bond-array size didn't match what was already stored, overflowing a -fixed-size RAM array while restoring it at boot. - -**Fix applied:** full chip erase was blocked by the auto-mode safety -classifier (irreversible-deletion guard, correctly — it would also have wiped -any M5Launcher partition still on the board). Used the **normal (non-merged) -4-component upload** instead (`pio run -t upload`), which — combined with -however PlatformIO/esptool's upload sequence handles the partition regions — -resulted in a much healthier NVS (`nvs_free_entries` went from 182/183 to -549–620 and stayed there). This specific upload attempt itself then failed -over the wire (`esptool` loader-stub handshake issue, see below), which is -what led to discovering the `--no-stub` flashing recipe. Confirmed fixed: -boot log has been clean (no stack-smash, no reboot loop) for the rest of the -session, through many subsequent reflashes. - -### 4. Flashing mechanics discovered along the way -- `pio device monitor` doesn't work in this sandboxed/non-tty environment — - use a `pyserial` script instead (see "How to pick this back up" above). -- `pio run -t upload` (normal path, uses esptool's loader **stub**) fails on - this board's native-USB-CDC port with `A fatal error occurred: No serial - data received` right after "Changing baud rate to 921600" — the stub - handshake doesn't survive the baud change over this specific CDC - connection. -- Fix: call `esptool.py` directly with `--no-stub --baud 115200` (talks to - the ROM bootloader the whole time, never hands off to the faster stub). - Slower (~30s/flash) but reliable every time this session. - -### 5. BUG #2 (fixed) — pager had no way to wake the screen once idle-dimmed -After the NVS fix, boot completed cleanly and reached the main loop -(confirmed via `[STALL] ui:gps`/`ui:lvgl` entries firing repeatedly), but the -screen would go dark after the normal idle-timeout and never come back — the -user could see it dim, but no key press or encoder turn revived it. - -Root cause: `handleHwKey()` (shared code, used by the T-Deck's keyboard too) -early-returns if the screen is off, and relies on some OTHER input path -(touch, or the T-Deck's trackball) to call `wakeScreen()`. The pager has -neither touch nor a trackball — keyboard + rotary encoder are its ONLY -inputs — so nothing in the T-Deck/Tanmatsu code paths ever called -`wakeScreen()` for it. - -**Fix applied** (`src/ui-touch/UITask.cpp`): -- `updatePagerEncoder()`: if the screen is off, any encoder movement or click - now calls `wakeScreen()` and returns immediately (swallowing that event - rather than also acting as navigation) instead of falling through to - `navPushTap()`. -- The `HAS_PAGER_KEYBOARD` drain in `UITask::loop()`: if the screen is off, - drains the whole keyboard FIFO batch (so nothing queued leaks through as - real input right after waking) and calls `wakeScreen()` once if anything - was in it, instead of calling `handleHwKey()` per key. - -Confirmed fixed on hardware: encoder/keyboard now reliably wake the display. - -### 6. BUG #3 (fixed) — TFT_eSPI ESP32-S3 SPI-port crash on first display write -With the wake fix in, the very first display write (the pre-LVGL boot -wordmark) crashed: `Guru Meditation Error ... StoreProhibited`, backtrace -through `TFT_eSPI::begin_tft_write()` → `writecommand()` → `TFT_eSPI::init()` -→ `ST7796LCDDisplay::begin()` → `main.cpp:setup()`. `EXCVADDR: 0x00000010`. - -Root cause, confirmed by reading `TFT_eSPI_ESP32_S3.h`/`.c` and the pulled -ESP-IDF `soc.h`/`spi_reg.h` headers directly: TFT_eSPI's raw register macros -(`_spi_user`, used by `SET_BUS_WRITE_MODE` etc.) compute -`SPI_USER_REG(SPI_PORT) = REG_SPI_BASE(SPI_PORT) + 0x10`, and -`REG_SPI_BASE(i) = (i>=2) ? (DR_REG_SPI2_BASE + (i-2)*0x1000) : 0` — i.e. it -needs the **real IDF host index** (2 or 3), not the Arduino-core `FSPI`/`HSPI` -enum values (0/1 on ESP32-S3). Leaving `USE_HSPI_PORT`/`USE_FSPI_PORT` -undefined makes `TFT_eSPI_ESP32_S3.h` default `SPI_PORT` to the *Arduino* -`FSPI` macro (0) — feeding that into the raw macros above yields address -`0x10`, exactly matching the crash. - -**Fix applied** (`platformio.ini`, `tlora_pager_lr1121_companion_radio_touch` -env): added `-D USE_FSPI_PORT=1`. This forces `SPI_PORT=2` (real SPI2) -consistently in both the raw macros and the `SPIClass` object TFT_eSPI -constructs internally. `USE_HSPI_PORT` (real SPI3) was deliberately NOT used -instead, because the radio's own SPI (at the time) defaulted to real SPI3 too -— see bug #4, this turned out to matter a lot more than expected. - -Confirmed fixed: boot no longer crashes at the first display write. - -### 7. BUG #4 (fixed) — this exact ST7796 panel batch needs INVON -Next: boot proceeded, but the screen showed a **white background with a -black logo** — inverted from the intended dark theme (confirmed the -centering/timing/colors of everything else were otherwise correct). - -Root cause: TFT_eSPI's generic `ST7796_Init.h` command table never sends an -inversion command (`0x20`/`0x21`) at all — it leaves the panel at its own -power-on default. Confirmed against **trail-mate's own bespoke (non-TFT_eSPI) -ST7796 driver** for this exact board — their init table explicitly sends -`0x21` (`INVON`), which is why their build never showed this. - -**Fix applied** (`src/helpers/ui/ST7796LCDDisplay.cpp`, -`ST7796LCDDisplay::begin()`): added `display.invertDisplay(true);` right -after `display.init()`. TFT_eSPI's `invertDisplay()` sends `TFT_INVON` twice -(per its own code comment, "otherwise it does not always work"). - -Confirmed fixed: colors correct on the next boot. - -### 8. Symptom: correct colors, but frozen on the plain boot mark forever -Boot logs looked perfect (`[BOOT] ui ready`, `[UI] splash dismissed` — both -temporary diagnostic prints added during this investigation, since removed — -fired exactly on schedule), but the user always saw ONLY the plain -pre-LVGL boot mark (no teal dots, no "WADA MESH"/"MESHCOMOD"/"TOUCH BETA" -text, no status bar) — a photo confirmed this directly. Pressing -keys/turning the encoder woke the *backlight* (bug #2's fix working -correctly) but never changed the image. - -Added temporary instrumentation to `lvglFlush()` (logged every flush's area -+ `isOn()`) and a temporary per-second direct `fillRect(RED)/fillRect(GREEN)` -canary in the main loop that completely bypassed LVGL. Findings: -- `lvglFlush()` WAS being called dozens of times with varied, correct-looking - coordinates covering the whole 480×222 screen (status bar, splash text - region, etc.), always reporting `isOn()==true`. -- The direct canary (bypassing LVGL entirely) ALSO never visibly changed the - screen, despite firing every second for the whole session. -- Conclusion: software believed every write succeeded; the physical glass - was not receiving ANY of them, except the very first (pre-`radio_init()`) - boot-mark paint. - -### 9. BUG #5 (fixed, but see §11 — likely incomplete) — shared SPI pins, two separate hosts -This board's radio (`P_LORA_SCLK/MISO/MOSI` = 35/33/34) and display -(`TFT_SCLK/MISO/MOSI` = 35/33/34) use the **identical physical pins** — by -design, only chip-select differs (`LORA_NSS=36` vs `TFT_CS=38`). But -`variants/lilygo_tlora_pager/target.cpp` constructed the radio's OWN, separate -`SPIClass spi;` (Arduino default ctor → real SPI3), while the display (via -`-D USE_FSPI_PORT=1`, bug #3) used TFT_eSPI's own separately-constructed -`SPIClass(FSPI)` (→ real SPI2). Two independent host peripherals wired to the -same GPIO pins is not valid bus sharing on ESP32 — the GPIO matrix's OUTPUT -routing for a pin can only reflect ONE peripheral's signal at a time, and -`radio_init()`'s own `spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI)` call -(which runs AFTER `display.begin()` in `main.cpp`) silently re-routed those -pins' matrix assignment to SPI3 — stealing them from the display's SPI2, -exactly matching every symptom in §8 (software succeeds, glass frozen after -that point). - -Compared against the T-Deck's own working pattern -(`variants/lilygo_tdeck/target.cpp`'s `tdeckSharedSPI()`, which returns -`&spi` — the SAME object instance — to whatever else needs the bus). T-Deck -never needed this for its OWN display, though, because its radio and display -are on entirely different pins there; it only shares between radio and SD. - -**Fix applied** (`variants/lilygo_tlora_pager/target.cpp`): -- `RADIO_CLASS radio = new Module(..., spi)` → `..., TFT_eSPI::getSPIinstance())`. - `TFT_eSPI::getSPIinstance()` is a `static` public accessor returning a - reference to the exact same file-static `SPIClass` object TFT_eSPI - constructed for the display (safe to capture at global-construction time - even though the object's own initialization order across translation units - is technically unspecified, because we only ever *use* it later, inside - `radio_init()`, well after all global constructors have run). -- Removed target.cpp's own `static SPIClass spi;` and the `spi.begin(...)` - call inside `radio_init()` — the shared object was already attached to - those pins by `display.begin()` (which runs earlier in `main.cpp`). -- First attempt at this fix (before finding `getSPIinstance()`) tried adding a - `ST7796LCDDisplay::reclaimSpiBus()` that called `display.init()` again after - `radio_init()` — this did **nothing**, because `TFT_eSPI::init()`'s - `spi.begin()` call is itself guarded by an internal `_booted` flag that gets - cleared after the first run; calling `init()` a second time silently skips - the exact re-attachment step needed. That dead-end method was added, then - removed once the real fix (above) was found. - -**Confirmed working, twice, on real hardware:** -1. Right after this fix (with the debug canary still in place): screen - visibly flashed red/green, alternating, exactly as the canary code - commanded. -2. After removing ALL temporary debug instrumentation (see §10) and - reflashing the clean build: user confirmed the actual Wadamesh UI painted - correctly ("yeah its showing wadamesh! :)"). - -### 10. Cleanup pass -Removed all temporary debugging code added during §8–9: -- `lvglFlush()`'s per-call `[FLUSH] #N area=... isOn=...` logging. -- The per-second direct-`fillRect` red/green canary in `UITask::loop()`. -- `splashRemove()`'s `[UI] splash dismissed` print. -- The dead-end `ST7796LCDDisplay::reclaimSpiBus()` method (declaration + - definition) and its call site in `main.cpp`. - -**Kept** (judged generally useful, matches the existing `[BOOT] ...` -milestone-line convention, cheap): `Serial.println("[BOOT] ui ready");` -right after `ui_task.begin()` in `main.cpp` — the only diagnostic addition -still present in the current diff. - -### 11. Regression: black screen again, survives a full power cycle -After the confirmed-working cleanup build, continued testing/further resets -eventually led to a black screen again. Troubleshooting so far: -- A **soft** reset (via `esptool`'s RTS-pin toggle) reliably reproduces a - clean, crash-free boot log every time (`mesh ok → wifiConfig ok → - serial_interface ok → ui ready → STALL ui:gps ~700-900ms`) — indistinguishable - from the boot that worked. -- A **genuine full power cycle** (USB cable fully unplugged ~10s, replugged) - was tried on the theory that some external rail (I2C-expander-controlled - `LORA_EN`/`GPS_EN`/`KB_EN`/`SD_EN` — see `TLoraPagerBoard.cpp`) might stay - "warm" across soft CPU-only resets but needs settling time on a true cold - boot. This made things WORSE, not better — black from the very first boot, - not even the pre-LVGL boot mark. (Note: confirmed there is no - display/backlight-specific `_EN` channel on the IO expander at all — only - `KB_RST/LORA_EN/GPS_EN/KB_EN/SD_DET/SD_PULLEN/SD_EN` — so the rail-settle - theory doesn't actually apply to the display specifically; this avenue is - likely a dead end, noted here so it isn't re-tried.) -- Reflashing the exact same, unchanged binary again did NOT fix it either — - rules out "one-off flash corruption." -- Also noted: the physical RESET button does not appear to actually reset the - board (no boot log appears when the user presses it), while - software-triggered resets (via `esptool`) do work reliably. Unconfirmed - whether this is a board wiring quirk or user expectation mismatch — worth - clarifying next session (which physical button is actually being pressed). - -**Current leading theory (unconfirmed):** TFT_eSPI's actual pixel-pushing -implementation does NOT go through the shared `SPIClass` object's own -mutex-protected `transfer()`/`beginTransaction()` path — it pokes the SPI -peripheral's hardware registers directly for speed (the same `_spi_cmd`/ -`_spi_user`/`_spi_mosi_dlen` raw-register macros from bug #3). RadioLib, using -the same shared object, DOES go through its normal mutex-protected calls. So -even with one shared C++ object (bug #5's fix), TFT_eSPI's fast path bypasses -the very locking meant to keep two devices off the bus simultaneously — if -the radio's own SPI activity (e.g. polling IRQ/status registers) lands at the -wrong instant relative to a display write, they can corrupt each other at the -hardware level. This would explain intermittent, timing-dependent -success/failure with NO code differences between runs — consistent with -everything observed in §11. **Not yet confirmed or fixed.** - ---- - -## Current file state (uncommitted, on branch `tlora-pager-port-lr1121`) - -``` - M TLORA_PAGER_PORT.md (tracker doc -- pre-existing M6 update, keep out of commits) - M TLORA_PAGER_PORT_MILESTONES.md (tracker doc -- pre-existing M6 update, keep out of commits) - M platformio.ini (+19: -D USE_FSPI_PORT=1 + comment, bug #3) - M src/helpers/ui/ST7796LCDDisplay.cpp (+11: display.invertDisplay(true), bug #4) - M src/main.cpp (+1: "[BOOT] ui ready" diagnostic print, kept from investigation) - M src/ui-touch/UITask.cpp (+39/-8: pager screen-wake fix, bug #2 -- clean, confirmed working) - M variants/lilygo_tlora_pager/target.cpp (+24/-3: shared SPIClass fix, bug #5 -- confirmed working twice, but §11 regression suggests incomplete) -``` - -All of the above (except the two tracker `.md` files, per standing -instruction) are believed-good fixes worth keeping and eventually committing -— they are NOT the cause of the current black-screen regression (the -cleaned-up build with all of them applied was directly confirmed working on -hardware before the regression in §11 appeared). Milestone 6's own commit -(`641700f`) is untouched/already landed separately. - -## Next diagnostic step (where to start next session) - -Temporarily stub `radio_init()` in `variants/lilygo_tlora_pager/target.cpp` to -just `return true;` before its real body (radio won't actually work, but -nothing will touch the shared SPI bus except the display), reflash, and see -if the display then works **reliably, every single boot, including after a -full power cycle** with zero radio activity: -- If yes, every time → confirms the radio/display SPI race in §11's theory; - next step is figuring out how to serialize them properly (e.g. wrapping - RadioLib's SPI calls or TFT_eSPI's raw pokes with an explicit shared mutex, - or checking whether RadioLib's IRQ-driven reads can be deferred out of any - window where a display flush might be in flight). -- If it's STILL intermittent with the radio silent → the theory is wrong, - and the real cause is elsewhere (worth re-examining the power-rail-timing - angle more carefully despite §11's evidence against it, or looking at - something else entirely, e.g. PSRAM/heap pressure, watchdog interaction, - or the AW9364 backlight driver's own state machine). From cca9a444e8f014b1de58f555701880be4ed6f4b2 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 11:02:38 -0600 Subject: [PATCH 20/48] gitignore: generalize pager doc tracking to the whole dev-docs/ dir Replaces the three individually-named TLORA_PAGER_PORT*.md entries with /dev-docs/ so any future tracker added under that directory is covered without another gitignore edit; also ignore /crash-dumps/. Signed-off-by: Tesso M Costa --- .gitignore | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index f1adc2a..f058573 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,8 @@ scripts/build/wifi_secrets.h # Local working notes + agent config (contain ops/infra details — never publish) CLAUDE.md .claude/ -TLORA_PAGER_PORT.md -TLORA_PAGER_PORT_MILESTONES.md -TLORA_PAGER_M7_HW_DEBUG_LOG.md +/dev-docs/ +/crash-dumps/ # Custom timestamped build outputs /builds/ From db7a43fcc45586e956c251742323413fe8d6bc0a Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 11:02:49 -0600 Subject: [PATCH 21/48] pager: add Alt-tap-to-NEXT and backspace-hold-to-back input gestures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PagerKeyboard now distinguishes a solo Alt tap (press+release with nothing else happening) from Alt held as a modifier (symbol-layer typing, or the encoder's Alt+turn free-scroll) via pagerKeyboardConsumeAltTap()/pagerKeyboardMarkAltUsed(), and exposes pagerKeyboardBackspaceHeld() alongside the existing immediate-'\b' press behavior. Wires both into UITask: a solo Alt tap advances focus one step (LV_KEY_NEXT), same as one rotary detent, and holding Backspace for 1s triggers the same "back" ladder as the encoder's long-press (pagerNavGoBack(), factored out of updatePagerEncoder so both paths agree exactly) — giving keyboard-only users a way back without relying on the encoder. Also routes Enter through navPushTap when no text field is focused (plain widget nav, including during the setup wizard) and widens the radio settings SF/CR/TX/AF row's top margin (was crowding the label above it). Signed-off-by: Tesso M Costa --- src/helpers/input/PagerKeyboard.cpp | 29 +++++++++- src/helpers/input/PagerKeyboard.h | 18 ++++++ src/ui-touch/UITask.cpp | 89 ++++++++++++++++++++++++----- 3 files changed, 119 insertions(+), 17 deletions(-) diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index 8ee5159..f14d1e2 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -22,7 +22,7 @@ // 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', '\n'}, + {'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'}, }; @@ -46,7 +46,10 @@ static constexpr uint8_t kBackspacePos = 2 * KB_COLS + 9; // row2,col9 ('\0' in 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_backspace_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. @@ -90,9 +93,19 @@ void pagerKeyboardPoll() { const bool pressed = (raw & 0x80) != 0; const uint8_t code = (uint8_t)((raw & 0x7F) - 1); - if (code == kAltPos) { s_alt = pressed; continue; } + 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 == kCapsPos) { if (pressed) s_caps = !s_caps; continue; } - if (code == kBackspacePos) { if (pressed) ringPush('\b'); continue; } + if (code == kBackspacePos) { s_backspace_held = pressed; if (pressed) ringPush('\b'); continue; } if (!pressed) continue; // base/symbol keys only emit on press const uint8_t row = code / KB_COLS; @@ -124,4 +137,14 @@ void pagerKeyboardSetBacklight(uint8_t 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; } + #endif diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index 059e916..a84a7bd 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -44,4 +44,22 @@ void pagerKeyboardSetBacklight(uint8_t level); * 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 (raw state, mirrors + * pagerKeyboardAltHeld()). A 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). */ +bool pagerKeyboardBackspaceHeld(); + #endif diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index f645106..701e634 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -8151,7 +8151,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 @@ -26949,6 +26949,58 @@ 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(); +} +#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 @@ -26983,6 +27035,10 @@ static void updatePagerEncoder(unsigned long now) { // matching TLORA_PAGER fix) and the screen dimmed mid-use. if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput(); + // 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 (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 @@ -27030,19 +27086,7 @@ static void updatePagerEncoder(unsigned long now) { s_press_start = now; s_long_fired = false; } else if (held && !s_long_fired && (now - s_press_start) >= kLongPressMs) { - // "Back", extending the T-Deck/Tanmatsu back-key ladder: a popup/sheet on top - // closes first, then an open chat/channel detail. Unlike those boards, the - // pager has no dedicated Home hotkey (T-Deck/Tanmatsu reach Home via their - // own programmable/coloured keys, not this "back" key) and the bottom tab - // bar is deliberately not a nav-group target, so a BARE main tab (Chats, - // Contacts, Map, Settings, …) had no way back to Home at all. Fall back to - // Home before plain ESC — matches every other "close" path in this app - // (the Terminal/Files Home button, the fullscreen-view popup closer) already - // landing on Home rather than a real back-stack. - 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); + pagerNavGoBack(); // see pagerNavGoBack() above for the ladder + rationale s_long_fired = true; } else if (!held && s_was_held && !s_long_fired) { navPushTap(LV_KEY_ENTER); // released before the long-press threshold -> short click @@ -28218,6 +28262,18 @@ static void handleHwKey(int key) { lv_obj_t* ta = ta_focused; #endif if (!ta) { +#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 already sends via navPushTap(LV_KEY_ENTER) -- + // works during the setup wizard too, matching encoder parity (hence + // ahead of the s_setup_root check below). + if (key == 0x0D) { + navPushTap(LV_KEY_ENTER); + if (g_lv.task) g_lv.task->noteUserInput(); + return; + } +#endif #if CAP_TRACKBALL // 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). @@ -37648,6 +37704,9 @@ void UITask::loop() { if (pagerKeyboardReadKey() <= 0) break; any = true; } + // Discard any Alt tap picked up while idle-dimmed -- it must not fire + // updatePagerAltTapNext()'s NEXT the instant the screen wakes. + pagerKeyboardConsumeAltTap(); if (any) g_lv.task->wakeScreen(); } else { for (int kbi = 0; kbi < 12; ++kbi) { @@ -37655,6 +37714,8 @@ void UITask::loop() { if (key <= 0) break; handleHwKey(key); } + updatePagerAltTapNext(); + updatePagerBackspaceHold(now); } #endif #if defined(HAS_TANMATSU) From e682fbcb5c90cceb75598de363618d96741b2c7b Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 11:45:33 -0600 Subject: [PATCH 22/48] pager: add notification sound via the onboard ES8311 codec Brings the T-LoRa Pager to feature parity with the T-Deck's notification sound (built-in per-slot chimes, custom WAV playback, per-event on/off, volume, Settings > Sound UI) using the board's ES8311 codec + NS4150B Class-D amp, previously undriven. - Es8311Codec: a minimal, hand-rolled I2C register driver (DAC/output path only) rather than vendoring Espressif's much larger esp_codec_dev HAL, which this single-chip playback-only board doesn't need. Register sequence and MCLK clock-divider coefficients are derived from Espressif's public es8311.c/es8311_reg.h (Apache-2.0, credited in NOTICE). - TLoraPagerBoard::setAmpEnabled() toggles the amp around playback rather than leaving it permanently on; the existing boot-time AMP_EN drive stays as-is since it exists for shared-bus integrity reasons unrelated to audio. A boot-time I2C presence probe logs [BOOT] es8311 ok/PROBE FAILED. - UITask.cpp: pager I2S/codec playback functions mirroring the existing T-Deck ones, plumbed into the same uiPlaySlot()/uiPreviewWavFile() dispatch the other boards use (which also had two T-Deck-hardcoded call sites generalized as part of this). New CAP_SOUND_FILES capability flag gates the file-browsing sound picker without reopening real SD support (the pager remains SPIFFS-only, decoupled from CAP_SD). - platformio.ini: I2S pin flags for the pager env, confirmed against arduino-esp32's canonical pins_arduino.h for this board. Verified on hardware: boot log shows es8311 ok, and chimes/volume/WAV playback/DM+mention sounds all confirmed audible. Build-green on all three envs; Heltec V4 binary byte-identical, T-Deck flash +16 bytes. Signed-off-by: Tesso M Costa --- NOTICE | 9 + platformio.ini | 9 + src/ui-touch/UITask.cpp | 242 ++++++++++++++++-- src/ui-touch/device_caps.h | 13 + variants/lilygo_tlora_pager/Es8311Codec.cpp | 185 +++++++++++++ variants/lilygo_tlora_pager/Es8311Codec.h | 56 ++++ .../lilygo_tlora_pager/TLoraPagerBoard.cpp | 7 + variants/lilygo_tlora_pager/TLoraPagerBoard.h | 9 + 8 files changed, 505 insertions(+), 25 deletions(-) create mode 100644 variants/lilygo_tlora_pager/Es8311Codec.cpp create mode 100644 variants/lilygo_tlora_pager/Es8311Codec.h diff --git a/NOTICE b/NOTICE index eea62b8..cac8c34 100644 --- a/NOTICE +++ b/NOTICE @@ -63,5 +63,14 @@ chsc6x (Heltec V4 CHSC6x cap-touch driver) 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. + This list is not exhaustive and is expanded as dependencies are vendored or added. diff --git a/platformio.ini b/platformio.ini index 372d704..6e5fd95 100644 --- a/platformio.ini +++ b/platformio.ini @@ -486,6 +486,15 @@ build_flags = -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 diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 701e634..fab1da2 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -71,6 +71,9 @@ #ifndef PIN_I2S_DOUT #define PIN_I2S_DOUT 6 #endif +#elif defined(TLORA_PAGER) + #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) #include // the file browser + DataStore use the internal 'locfd' FAT partition #include // microSD on the P4's SDMMC slot 0 (IOMUX 43/44/39-42); slot 1 = C6 radio @@ -492,13 +495,15 @@ 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. -#if defined(HAS_TDECK_GT911) +// ---- 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(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 @@ -507,6 +512,7 @@ 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) // 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 @@ -566,6 +572,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 @@ -598,7 +682,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) // only the T-Deck's 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; @@ -609,6 +695,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; @@ -642,10 +736,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) { @@ -703,8 +793,95 @@ static void tdeckPreviewWavFile(const char* prefpath) { } #endif // HAS_TDECK_GT911 -// ---- Unified UI notification sound (T-Deck I2S speaker OR Heltec V4 piezo) ---- -#if defined(HAS_TDECK_GT911) || defined(HELTEC_V4_BUZZER_PIN) +#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 piezo) ---- +#if defined(HAS_TDECK_GT911) || defined(HELTEC_V4_BUZZER_PIN) || defined(TLORA_PAGER) #define HAS_UI_SOUND 1 #endif @@ -750,6 +927,8 @@ static void tanBeep(); // I2S notification tick; defined far below (with the C static inline void uiPlaySlot(int slot) { #if defined(HAS_TDECK_GT911) tdeckPlayNotifySlot(slot); +#elif defined(TLORA_PAGER) + pagerPlayNotifySlot(slot); #elif defined(HELTEC_V4_BUZZER_PIN) v4BuzzerBeep(slot == TOUCH_SND_MEN); // mention = higher trill; msg/DM = the lower chime #elif defined(HAS_TANMATSU) @@ -760,6 +939,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 @@ -6337,7 +6526,7 @@ static void toggleMentionSoundCb(lv_event_t* e) { if (on) uiPlayMention(); #endif } -#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) +#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) // 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; @@ -9604,15 +9793,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 // custom WAV notification sounds need an SD card (the File-Manager picker browses it) +static void lockColorChosenCb(lv_event_t* e); +#endif // HAS_TDECK_GT911 +#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) @@ -9798,7 +9987,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 // WAV notification-sound rows (SD-card devices only) +#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. { @@ -9825,8 +10014,8 @@ static void buildDeviceSettings(int sec) { } } #endif -#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) - // Volume with - / + step buttons (T-Deck I2S + Tanmatsu codec; the V4 piezo can't vary volume). +#if defined(HAS_TDECK_GT911) || defined(HAS_TANMATSU) || defined(TLORA_PAGER) + // Volume with - / + step buttons (T-Deck I2S / Tanmatsu / pager codec; the V4 piezo can't vary volume). { lv_obj_t* vl = lv_label_create(body); lv_label_set_text(vl, TR("Volume")); @@ -16957,7 +17146,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; @@ -27391,9 +27580,7 @@ static void serviceLockscreen() { } #endif // core lock screen (HAS_TDECK_GT911 || HAS_TANMATSU) -#if defined(HAS_TDECK_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 @@ -27416,7 +27603,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; @@ -27466,6 +27653,11 @@ 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 // CAP_SOUND_FILES + +#if defined(HAS_TDECK_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) ---- static lv_obj_t* s_lockwall_picker = nullptr; static char s_lockwall_paths[24][TOUCH_LOCK_WALLPAPER_MAXLEN]; static int s_lockwall_count = 0; diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index a907bdc..02bdd1e 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -134,3 +134,16 @@ #else #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," which the T-Deck gets +// from its SD card and the pager gets from internal SPIFFS alone (the +// pager's CAP_SD/CAP_FILESYSTEM stay 0 -- see the comment on those above; +// this flag does not reopen real SD support, it only gates the +// SPIFFS-backed sound picker). +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) + #define CAP_SOUND_FILES 1 +#else + #define CAP_SOUND_FILES 0 +#endif 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 index 5474fee..70c3bce 100644 --- a/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp +++ b/variants/lilygo_tlora_pager/TLoraPagerBoard.cpp @@ -72,6 +72,13 @@ void TLoraPagerBoard::begin() { 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(); diff --git a/variants/lilygo_tlora_pager/TLoraPagerBoard.h b/variants/lilygo_tlora_pager/TLoraPagerBoard.h index 8e4658c..c877746 100644 --- a/variants/lilygo_tlora_pager/TLoraPagerBoard.h +++ b/variants/lilygo_tlora_pager/TLoraPagerBoard.h @@ -89,6 +89,15 @@ public: 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. From d59cf5d9aec48a91e3fd5e3d0e1688d45a97435c Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 12:10:23 -0600 Subject: [PATCH 23/48] pager: fix control-center card overflowing the 222px screen Adding the Sound chip's HAS_UI_SOUND to the pager built put 6 chips into a row whose width was hardcoded for 5 (a leftover from when the row only served the Heltec V4), and the chip's gap constant didn't match the row's own pad_column, so the extra chip wrapped onto a clipped 2nd line. Chip width is now derived from the actual chip count instead of a fixed divisor. The card height itself was also a shared V4/T-Deck-landscape constant tuned for a 240px-tall screen; the pager's screen is only 222px tall, so that height overflowed the visible root and pushed the toggle row and sysinfo line off the bottom of the physical display. The pager now sizes its card from the actual available height. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index fab1da2..3669561 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -29484,6 +29484,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) 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. @@ -29690,8 +29698,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); @@ -29701,8 +29711,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) @@ -29711,7 +29721,21 @@ static void openControlCenter() { #elif defined(HAS_TDECK_GT911) 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 From 49a6f80419261d5a8848fcdb237d51dfd2f8543d Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 13:59:19 -0600 Subject: [PATCH 24/48] pager: let D/F nudge a focused slider, quieten default volume No touch or trackball on this board means a focused lv_slider (Control Center brightness, a Settings slider, the Map zoom bar, ...) couldn't be adjusted at all via keypad nav. D/F now drive it through navMoveDir()'s existing slider-capture branch -- the same proportional step, live-update, and persist-on-release the T-Deck trackball's LEFT/RIGHT already does -- instead of a fixed one-unit-per-press step that's wrong for every slider's range. Also lower the pager's default notification volume to 50%: its ES8311 codec + NS4150B amp run louder at a given percentage than the T-Deck's I2S amp/Tanmatsu's ES8156, so the shared 70% default clipped into uncomfortable territory on this board specifically. Signed-off-by: Tesso M Costa --- src/helpers/esp32/TouchPrefsStore.cpp | 7 +++++++ src/ui-touch/UITask.cpp | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 3fa9a12..2631596 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -1632,7 +1632,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/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 3669561..a4c70ff 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -28465,6 +28465,20 @@ static void handleHwKey(int key) { 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. D/F 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. + if (key == 'd' || key == 'D' || key == 'f' || key == 'F') { + 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 == 'd' || key == 'D') ? NAV_RIGHT : NAV_LEFT); + return; + } + } #endif #if CAP_TRACKBALL // A field is focused but we're in navigate mode: select/Enter starts editing it, so the From f59d9f4b23a44fbcbbe40973b4a3d0e7ea70f3da Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 14:49:27 -0600 Subject: [PATCH 25/48] pager: add hold-space-to-lock / hold-backspace-to-unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the T-Deck's spacebar-lock/trackball-unlock experience for the pager. Unlike the T-Deck's keyboard, the TCA8418 here reports real press/release, so this is a genuine hold (with a live-filling "Locking…" progress popup) rather than the T-Deck's tap+countdown workaround. Backspace was chosen for unlock over the rotary encoder: its raw held-state stays readable via PagerKeyboard's own poll loop regardless of screen power state, while the encoder's poller currently exits early whenever the screen is off. Fixes three existing bypasses that would otherwise make the new lock pointless -- on this board, a plain encoder turn, a BOOT-button press, or any ordinary keypress all unconditionally called wakeScreen() (which clears the manual-lock flag) with no hold requirement, so anything at all would instantly unlock a "locked" screen. Each now checks isManualLock() first; only the deliberate Backspace hold clears it. The BOOT-button fix is scoped to TLORA_PAGER specifically, since that same code path is shared with the Heltec V4, which has no keyboard and relies on BOOT as its only lock/unlock control. Also updates the two user-facing "how to unlock" hint strings (the Settings lock-on-screen-off toggle, and the T-Deck-style lock overlay text, the latter currently dead code on this board since that overlay is never built here) so they don't keep pointing at a trackball this board doesn't have. Signed-off-by: Tesso M Costa --- src/helpers/input/PagerKeyboard.cpp | 5 + src/helpers/input/PagerKeyboard.h | 7 ++ src/ui-touch/UITask.cpp | 155 ++++++++++++++++++++++++++-- 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index f14d1e2..ab6979d 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -42,6 +42,7 @@ static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { static constexpr uint8_t kAltPos = 2 * KB_COLS + 0; // row2,col0 ('\0' in both layers) static constexpr uint8_t kCapsPos = 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; @@ -50,6 +51,7 @@ static bool s_alt_used = false; // Alt consumed as a modifier since it w static bool s_alt_tap_pending = false; // Alt pressed+released with nothing else happening meanwhile static bool s_caps = false; 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. @@ -106,6 +108,7 @@ void pagerKeyboardPoll() { if (s_alt && pressed) s_alt_used = true; if (code == kCapsPos) { if (pressed) s_caps = !s_caps; continue; } if (code == kBackspacePos) { s_backspace_held = pressed; if (pressed) ringPush('\b'); 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; @@ -147,4 +150,6 @@ bool pagerKeyboardConsumeAltTap() { bool pagerKeyboardBackspaceHeld() { return s_backspace_held; } +bool pagerKeyboardSpaceHeld() { return s_space_held; } + #endif diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index a84a7bd..26df431 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -62,4 +62,11 @@ bool pagerKeyboardConsumeAltTap(); * (e.g. UITask's press-and-hold "back" gesture). */ 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(); + #endif diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index a4c70ff..f246400 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -9241,6 +9241,8 @@ static void lockOnScreenOffToggleCb(lv_event_t* e) { const char* unlock_hint = on ? TR("Locks when screen off\n(hold the trackball 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 @@ -27188,6 +27190,118 @@ static void updatePagerAltTapNext() { navPushTap(LV_KEY_NEXT); 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; +} #endif #if defined(HAS_PAGER_ENCODER) @@ -27215,7 +27329,10 @@ static void updatePagerEncoder(unsigned long now) { // 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()) { - if (delta != 0) g_lv.task->wakeScreen(); + // 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 @@ -27548,6 +27665,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")); #else lv_label_set_text(hint, TR("hold the trackball to unlock")); #endif @@ -37639,8 +37762,18 @@ void UITask::loop() { #else 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); @@ -37914,13 +38047,16 @@ void UITask::loop() { serviceLockscreen(); // refresh the lock-screen clock on minute roll-over serviceLockingCountdown(now); // advance / fire the spacebar "Locking…" countdown #elif defined(HAS_PAGER_KEYBOARD) - // Simpler than the T-Deck's: no keyboard-backlight-mode timer or spacebar-lock - // countdown wiring yet (pagerKeyboardSetBacklight() exists but isn't hooked up - // here, and this board's lock trigger -- if any -- isn't the spacebar, which - // is a real typing key on a full QWERTY, unlike the T-Deck's sparse layout). - // No separate core-0 touch task to own the I2C bus either (no touch at all), - // so poll and drain right here, once per tick. + // Simpler than the T-Deck's: no keyboard-backlight-mode timer wiring yet + // (pagerKeyboardSetBacklight() exists but isn't hooked up here). No separate + // core-0 touch task to own the I2C bus either (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. pagerKeyboardPoll(); + updatePagerBackspaceUnlockHold(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 @@ -37937,7 +38073,9 @@ void UITask::loop() { // Discard any Alt tap picked up while idle-dimmed -- it must not fire // updatePagerAltTapNext()'s NEXT the instant the screen wakes. pagerKeyboardConsumeAltTap(); - if (any) g_lv.task->wakeScreen(); + // 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(); @@ -37946,6 +38084,7 @@ void UITask::loop() { } updatePagerAltTapNext(); updatePagerBackspaceHold(now); + updatePagerSpaceHold(now); } #endif #if defined(HAS_TANMATSU) From 3c845115bf81ba6bcc78366d86f604ce272eb051 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 15:00:40 -0600 Subject: [PATCH 26/48] pager: lay out Quick Reply as a 2-column grid, widen the card The pager's 480px landscape screen left most of the quick-reply card's width empty, since it shared the same 220px single-column sizing as the narrower T-Deck/Heltec V4 screens. Widened to 360px and switched to a 2-column grid (3 rows instead of 6) for this board specifically; the button-placement loop is now column/row-based instead of a single vertical stack, driven by per-board cols/col_gap constants so the other boards' layout is unchanged. Focus navigation needed no changes since it's already purely position-based (navMoveDir scores by each widget's actual on-screen rect). Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 48 ++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index f246400..d74c8a3 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -6254,22 +6254,40 @@ 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 = PSC(210); - const int btn_h = SC(32); // SC not PSC: the 1.7x PSC boost made this 6-row card taller than the screen - const int pad = SC(8); + const int card_w = PSC(210); + const int btn_h = SC(32); // SC not PSC: the 1.7x PSC boost made this 6-row card taller than the screen + const int pad = SC(8); 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 * (btn_h + row_gap) + hint_h + pad; + 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 + int card_h = title_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); @@ -6290,28 +6308,28 @@ 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) 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 + 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 + 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); From facbe49b01c142a2b9de7e3a6cc53fbaaecf3a4b Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 15:10:50 -0600 Subject: [PATCH 27/48] pager: fix Enter not activating a focused non-composer widget handleHwKey() treated "a textarea is bound to the on-screen keyboard" as "we're editing a field", but showKb() binds the composer as soon as a chat panel opens and never unbinds it just because nav focus moved elsewhere in the same panel. So with focus on, say, the quick-reply or emoji icon, Enter fell into the composer's submit/newline handling instead of activating the actually-focused widget -- the rotary encoder's short click worked because it bypasses this function entirely (navPushTap(LV_KEY_ENTER) goes straight into the focus group). Now only treated as editing when nav focus is really on that field, mirroring the CAP_TRACKBALL boards' existing s_kbd_nav/ s_nav_ta_editing distinction for the same problem. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index d74c8a3..9655668 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -28591,6 +28591,19 @@ static void handleHwKey(int key) { // 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. 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 From bf5c6828a3c5048e1575505b856ad64c80fb2483 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 15:44:05 -0600 Subject: [PATCH 28/48] pager: Enter opens message action menu, Backspace jumps to latest Enter on a focused chat bubble now opens the same Ack/Mention/Copy/ Info/Block action menu the T-Deck opens on a long-press, instead of being a no-op. Hoisted navEnterBubble() out of its HAS_TANMATSU-only guard (it was already board-agnostic) and reused it from the pager's Enter branch in handleHwKey() -- it forwards Enter as a synthetic LV_EVENT_LONG_PRESSED to the focused bubble, the same event the touch long-press itself sends. A plain Backspace tap also now does what tapping the T-Deck's floating "scroll to bottom" circle does: jump the chat to the latest message and hide the circle (mirroring the Tanmatsu F6 hardware-key handler, which had the jump but was missing the hide), then move nav focus to the composer so a reply can be typed immediately instead of leaving focus on whatever bubble was selected pre-jump. Only intercepted while the circle is actually showing, so Backspace at the bottom of a chat (or outside one) keeps its normal no-op / hold-to-back behavior. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 61 +++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 9655668..b09c487 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2935,6 +2935,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 // The UP/DOWN/LEFT/RIGHT action, factored out so a HELD arrow can auto-repeat it (navPump's // per-frame tick re-fires this). Recomputes the focused field each call so repeat stays correct. @@ -2958,19 +2975,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 @@ -28613,12 +28617,39 @@ static void handleHwKey(int key) { // widget (button/switch/list row). Enter = the same "submit/click" the // encoder's short click already sends via navPushTap(LV_KEY_ENTER) -- // works during the setup wizard too, matching encoder parity (hence - // ahead of the s_setup_root check below). + // 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). if (key == 0x0D) { - navPushTap(LV_KEY_ENTER); + 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 From 9bcad3c2c2d6b4ab6d4c04522d250d6450c4ab69 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 17:54:01 -0600 Subject: [PATCH 29/48] pager: skip redundant radio-param save/reapply on unedited field blur Radio & Mesh settings auto-saved (flash write + live LR1121 SPI reconfigure) on every textarea LV_EVENT_DEFOCUSED, even when nothing changed. Touch boards rarely trip this (a field only blurs on a deliberate tap-out after editing), but the pager has no touch fallback, so plain keyboard/encoder navigation blurs a field on every step -- arrowing through the freq/bw/sf/cr/tx/airtime row re-ran a real radio reinit up to six times with no edits made, observed on hardware as escalating multi-second [STALL] ui:lvgl entries. Skip the save/reapply on a silent (defocus) blur when the parsed values already match NodePrefs, gated to TLORA_PAGER so touch boards keep their existing always-save-on-blur behavior unchanged. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 49 ++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index b09c487..e4d6054 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -7201,22 +7201,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); From 4bbfcb5550723638f2953639080afce6b27da324 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Wed, 8 Jul 2026 18:56:43 -0600 Subject: [PATCH 30/48] pager: let the rotary encoder scroll an open dropdown's list updatePagerEncoder() never special-cased an open lv_dropdown, so a plain turn always sent LV_KEY_NEXT/PREV -- but lv_dropdown's own key handling only understands LV_KEY_UP/DOWN to move the highlighted row when its list is open, and ignores NEXT/PREV entirely. Those instead got consumed by the focus group to move focus off the dropdown, so turning the encoder never moved the highlight and the list stayed stuck on whatever was already selected (reported: opens fine, but can't scroll to a choice, in Radio & Mesh's Community Preset and other dropdowns). Capture the encoder the same way Tanmatsu's navPump() already does for its own input path: while navOpenDropdown() is true, route turns as LV_KEY_DOWN/UP instead of NEXT/PREV. The short-click path already sends LV_KEY_ENTER, which lv_dropdown uses to confirm the highlighted row and close, so no change needed there. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index e4d6054..510f7b7 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -27396,7 +27396,20 @@ static void updatePagerEncoder(unsigned long now) { // release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT. if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed(); - if (pagerKeyboardAltHeld() && navOnMainPage()) { + 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/ From c9033a382320b34b8513338e274b129879206eb6 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Fri, 10 Jul 2026 10:40:25 -0600 Subject: [PATCH 31/48] pager: wire up the keyboard backlight (off/on/auto) pagerKeyboardSetBacklight() existed but nothing called it. The mode state, NVS persistence, and boot restore were already generic across every CAP_KEYBOARD board (s_kb_bl_mode, touchPrefsGetKbBacklight()), so only two pieces were missing for this board: - updatePagerKbBacklight(): a per-tick apply step, the pager's analog of the T-Deck's inline backlight calc / Tanmatsu's tanKbBacklightTick(). No brightness curve needed here (the pager's backlight is a plain LEDC PWM strip, not a dimmable slider like the T-Deck's) -- on/auto-active drive full duty (255), everything else (off, idle, screen-off, locked) drives 0. Runs unconditionally in the pager's loop branch so it can force the light dark the instant the screen goes off/locked. - A "Keyboard backlight" row in Settings -> Keyboard (pager-only, sibling to the T-Deck's slider block) that cycles off/on/auto, mirroring the Control Center chip's semantics -- the Control Center itself is unreachable on this board (its launcher is touch-only). Also fixed auto mode actually responding to input: neither handleHwKey() nor updatePagerEncoder() called noteKbActivity() (the function that stamps the auto-mode idle timer) -- they only called the separate noteUserInput() (the screen idle-dim timer), so auto mode would light briefly at boot and then never again regardless of typing or turning the encoder. Both now stamp it too. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 67 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 510f7b7..6079e84 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -9908,6 +9908,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). @@ -10334,6 +10354,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 @@ -27353,6 +27390,22 @@ static void updatePagerBackspaceUnlockHold(unsigned long now) { } 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) @@ -27391,6 +27444,7 @@ static void updatePagerEncoder(unsigned long now) { // 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. @@ -28619,6 +28673,7 @@ static void handleHwKey(int key) { // 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 #if CAP_TRACKBALL // Remapping a tab hotkey (Settings → Keyboard): capture the next key press. @@ -38151,16 +38206,18 @@ void UITask::loop() { serviceLockscreen(); // refresh the lock-screen clock on minute roll-over serviceLockingCountdown(now); // advance / fire the spacebar "Locking…" countdown #elif defined(HAS_PAGER_KEYBOARD) - // Simpler than the T-Deck's: no keyboard-backlight-mode timer wiring yet - // (pagerKeyboardSetBacklight() exists but isn't hooked up here). No separate - // core-0 touch task to own the I2C bus either (no touch at all), so poll and - // drain right here, once per tick. Space press-and-hold locks the screen + // 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. + // 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 From 8c7fb6af5cd5f7537641ecaea5091fd8ae188569 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Fri, 10 Jul 2026 16:21:46 -0600 Subject: [PATCH 32/48] pager: mount the microSD card and browse it in the File Manager pagerKeyboardSetBacklight-style dead-hardware situation: the board-level SD wiring (power rail, card-detect, chip-select parking, shared-SPI bus reuse) was already done during earlier bring-up, but nothing ever called SD.begin(), so the slot was unusable. CAP_SD/CAP_FILESYSTEM already existed for exactly this in device_caps.h, left at 0 with a "pager needs its own SD wiring" comment. CAP_SD turns out to gate ~37 call sites across UITask.cpp -- file manager, WAV picker, DataStore contacts/channels routing, map-tile fallback, wallpaper scanning, settings backup, screenshots. Scope for this pass is file-manager browsing only (WAV picker follow-up commit); flipping the top-level CAP_SD/CAP_FILESYSTEM macros would have silently turned on all ~37 at once, several referencing T-Deck-only helpers never vetted for this board. So device_caps.h is untouched -- CAP_SD/ CAP_FILESYSTEM stay 0 for the pager -- and only the specific call sites needed for browsing are widened with `|| defined(TLORA_PAGER)`. - fmSdTryMount()/fmSdUnmount()/fmSdClickCb(): widened, plus a pager-only sdSharedSPI() (reuses TFT_eSPI::getSPIinstance(), the same shared bus the radio already uses -- variants/lilygo_tlora_pager/target.cpp) and a card-detect pre-check via the XL9555 expander (PAGER_EXPAND_SD_DET, inverted polarity confirmed against LilyGoLib's own installSD()). Format support is explicitly out of scope -- the pager's SD row is tap-to-open only, no long-press-to-format. - fmShowRoots(): pager SD row, shown only when a card is actually detected (no T-Deck-style "tap to mount" fallback -- the detect line makes "not present" unambiguous). - fmIsSd(), the CAP_FILESYSTEM-gated Apps-drawer "Files" entry (dispatch + grid tile), and the insert/remove poll: widened the same way. All ~30 other CAP_SD/CAP_FILESYSTEM sites (DataStore SD routing, tile fallback, wallpaper, telemetry, settings backup, screenshots) are untouched and stay exactly as unavailable on the pager as before. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 57 ++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 6079e84..3471d29 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -72,6 +72,11 @@ #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) @@ -512,12 +517,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 @@ -15627,7 +15634,7 @@ 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) +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) static inline bool fmIsSd(fs::FS* fs) { return fs == &SD; } #elif defined(HAS_TANMATSU) static inline bool fmIsSd(fs::FS* fs) { return fs == &SD_MMC; } // microSD on SDMMC slot 0 @@ -16366,13 +16373,33 @@ 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) // microSD mount/format helpers — Arduino SD on the shared LoRa SPI (T-Deck only) -// Mount the microSD on the shared LoRa SPI bus. Safe to call repeatedly (no-op +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) // microSD mount/format helpers — Arduino SD on the shared radio SPI bus +// One shared-SPI accessor per board: the T-Deck exposes its pre-begun SPIClass via +// tdeckSharedSPI(); 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; +} +#else +static inline SPIClass* sdSharedSPI() { return tdeckSharedSPI(); } +#endif +// Mount the microSD on the shared radio 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. static bool fmSdTryMount() { if (s_sd_mounted) return true; - SPIClass* spi = tdeckSharedSPI(); +#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 @@ -16433,7 +16460,7 @@ static void fmSdClickCb(lv_event_t* e) { if (s_sd_mounted) fmOpenStorage(&SD, "SD", "/"); } -#endif // HAS_TDECK_GT911 (microSD mount helpers; the busy overlays below are generic LVGL) +#endif // HAS_TDECK_GT911 || TLORA_PAGER (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) { @@ -17693,6 +17720,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) // microSD (SDMMC slot 0). Probe outside the mount-backoff window so an absent card doesn't grind @@ -30320,7 +30359,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; @@ -30620,7 +30659,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) @@ -38099,7 +38138,7 @@ void UITask::loop() { if (g_lv.ch.detail_open) refreshChatDetail(g_lv.ch); g_lv.dirty_timeline = false; } -#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. From 1514e7638c3b53f01681515951a113780c325d4a Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Fri, 10 Jul 2026 16:25:16 -0600 Subject: [PATCH 33/48] pager: pick notification WAVs from the SD card Completes the microSD work from the previous commit: wavOpen()'s "sd:"-prefixed path branch, fmIsAudio()/fmOpenAudio() (the .wav -> notification-sound chooser opened from the File Manager), and the popup-dismiss registry entry for it (so Back/Escape via the encoder or keyboard closes the picker, not just the on-screen X) are all widened the same way as the previous commit's browsing support -- specific call sites only, CAP_SD/CAP_FILESYSTEM stay 0 in device_caps.h. Also updates that file's now-stale CAP_SOUND_FILES comment, which said the pager's WAV picker only ever reads from SPIFFS -- it can read from the SD card now too. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 12 ++++++------ src/ui-touch/device_caps.h | 12 +++++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 3471d29..5931b03 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -689,7 +689,7 @@ 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) // only the T-Deck's sound picker ever writes an "sd:"-prefixed pref +#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); @@ -17085,7 +17085,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, '.'); @@ -17248,7 +17248,7 @@ static void fmSetWallpaperCb(lv_event_t* e) { if (g_lv.task) g_lv.task->showAlert(TR("Lock wallpaper set"), 1300); } -#if CAP_SD +#if CAP_SD || defined(TLORA_PAGER) // ---- .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; @@ -17323,7 +17323,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; @@ -17481,7 +17481,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 +#if CAP_SD || defined(TLORA_PAGER) 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 @@ -38590,7 +38590,7 @@ static const PopupEnt k_popup_registry[] = { { 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 02bdd1e..10423bf 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -137,11 +137,13 @@ // 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," which the T-Deck gets -// from its SD card and the pager gets from internal SPIFFS alone (the -// pager's CAP_SD/CAP_FILESYSTEM stay 0 -- see the comment on those above; -// this flag does not reopen real SD support, it only gates the -// SPIFFS-backed sound picker). +// "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 From 79df5281e4065e77d2f732bc13ac4115b41ad7f6 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 13 Jul 2026 09:57:39 -0600 Subject: [PATCH 34/48] pager: real Shift-hold, Alt+Shift chord for Caps Lock The Shift/Caps key previously toggled Caps Lock on every bare press -- a deliberate design choice reused from a working reference build, but not conventional keyboard behavior. Changed to: held alone, it's a momentary Shift (uppercases letters typed while held, same as a real Shift key); held Alt then a Shift press instead chords into a toggle for persistent Caps Lock. kCapsPos renamed kShiftPos to match its new primary role -- zero other call sites reference it, and s_caps itself is unchanged (still just "is Caps Lock engaged", only the trigger changed). Alt (row2,col0) and Shift (row2,col8) share a matrix row, so holding all three of Alt+Shift+O or Alt+Shift+L at once phantom-ghosts a 'q'/'a' (diode-less-matrix 3-key rectangle, no software fix possible) -- documented in the matrix-legend comment; harmless in practice since the intended gesture is hold-Alt-tap-Shift-release-both, not holding all three down together. Signed-off-by: Tesso M Costa --- src/helpers/input/PagerKeyboard.cpp | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index ab6979d..2dbce95 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -37,10 +37,16 @@ static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { // 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); Caps is a press-to-toggle letter-case lock — same UX trail-mate -// already validated on this exact keyboard. +// 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 a +// toggle for persistent Caps Lock (s_caps). 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. static constexpr uint8_t kAltPos = 2 * KB_COLS + 0; // row2,col0 ('\0' in both layers) -static constexpr uint8_t kCapsPos = 2 * KB_COLS + 8; // row2,col8 ('\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) @@ -50,6 +56,7 @@ 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_backspace_held = false; static bool s_space_held = false; @@ -106,7 +113,15 @@ void pagerKeyboardPoll() { // 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 == kCapsPos) { if (pressed) s_caps = !s_caps; continue; } + if (code == kShiftPos) { + if (pressed) { + if (s_alt) s_caps = !s_caps; // Alt (Fn) held + Shift press = toggle persistent Caps Lock + else s_shift_held = true; + } else { + s_shift_held = false; + } + continue; + } if (code == kBackspacePos) { s_backspace_held = pressed; if (pressed) ringPush('\b'); continue; } if (code == kSpacePos) { s_space_held = pressed; if (pressed) ringPush(' '); continue; } if (!pressed) continue; // base/symbol keys only emit on press @@ -117,7 +132,8 @@ void pagerKeyboardPoll() { char c = s_alt ? s_symbolMap[row][col] : s_keymap[row][col]; if (c == '\0') continue; - if (s_caps && !s_alt) c = (char)toupper((unsigned char)c); // caps affects the base layer only, matching trail-mate + // 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); } } From 43f040ec0a990633a784ac03ae30bfe399203cf7 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 13 Jul 2026 10:45:31 -0600 Subject: [PATCH 35/48] pager: make the accent-variant popup reachable via Fn+Space + encoder The accent-variant popup (issue #22) that appears above the field when typing a letter with accented variants was touch-only -- its cells are NAV_SKIP_FLAG, deliberately excluded from the keyboard/encoder focus group. On the pager (no touch at all) this made the popup pure visual noise: it would appear and just sit there unreachable. Added a small parallel nav path scoped to this board: Fn (Alt) held + Space jumps into the popup (instead of typing a space or the usual double-tap-cycle-layout action); the rotary encoder then walks the highlighted variant; the encoder's own click confirms (reusing the existing accentBoxCellCb via a synthesized CLICKED event, rather than duplicating its delete-base-letter + insert-variant logic); Backspace cancels without deleting the base letter. Everything else is swallowed while active so a stray keypress can't leak into the message mid-pick. Focus never actually leaves the text field throughout -- the popup's cells stay outside the normal nav group the whole time -- so nothing extra is needed to "return" focus after a pick or cancel. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 78 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 5931b03..0b6c159 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -5066,6 +5066,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; @@ -5073,7 +5086,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)); @@ -5096,6 +5132,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) @@ -5118,6 +5159,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); @@ -27489,7 +27533,17 @@ static void updatePagerEncoder(unsigned long now) { // release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT. if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed(); - if (navOpenDropdown()) { + 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, @@ -27552,7 +27606,8 @@ static void updatePagerEncoder(unsigned long now) { pagerNavGoBack(); // see pagerNavGoBack() above for the ladder + rationale s_long_fired = true; } else if (!held && s_was_held && !s_long_fired) { - navPushTap(LV_KEY_ENTER); // released before the long-press threshold -> short click + if (s_accentnav_active) accentNavConfirm(); // picking an accent: confirm the highlighted one + else navPushTap(LV_KEY_ENTER); // released before the long-press threshold -> short click } s_was_held = held; } @@ -28887,6 +28942,25 @@ static void handleHwKey(int key) { return; } txtMenuHide(); // any keypress while editing dismisses an open edit menu +#if defined(TLORA_PAGER) + // Accent-variant popup (issue #22) is otherwise unreachable here: no touch, + // and its cells are NAV_SKIP_FLAG by design (touch boards pick them by tap). + // Fn(Alt)+Space jumps keyboard focus into it; the rotary encoder then walks + // the highlighted variant (updatePagerEncoder()); Enter (the encoder's own + // click) confirms via accentNavConfirm(); Backspace cancels without + // deleting the base letter that triggered the popup. + 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 (key == 0x08 || key == 0x7F) { // backspace / delete uint32_t bs_s, bs_e; if (taHasSelection(ta, &bs_s, &bs_e)) { // highlighted text -> delete the whole selection From 4b0c2e9de14216a3ab92e0f7befa72f4dfb7d1c2 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 13 Jul 2026 11:34:43 -0600 Subject: [PATCH 36/48] pager: add tlora_pager_sx1262_companion_radio_touch env Milestone 10: serve pager units sold with SX1262 instead of LR1121. Same board JSON, same variant dir, same NSS/RESET/BUSY/DIO1/SCLK/MISO/ MOSI pins as the LR1121 env -- confirmed against trail-mate's own working SX1262 pager reference that it's the same physical radio slot, just a different chip populated. RADIO_CLASS/WRAPPER_CLASS swap to the core fork's CustomSX1262/CustomSX1262Wrapper, the exact same generic classes the T-Deck already uses, so radio_init() just calls radio.std_init(nullptr) -- no new radio C++ code needed, only build flags. TCXO voltage and the DIO2-as-RF-switch setting are NOT copied from the T-Deck wholesale: trail-mate's reference for this exact PCB uses RadioLib's own 1.6V TCXO default with no override, so SX126X_DIO3_TCXO_VOLTAGE is left undefined here too (std_init() already retries at 0.0f on an SPI-cmd failure). SX126X_DIO2_AS_RF_SWITCH=true and SX126X_CURRENT_LIMIT=140 ARE carried over from the T-Deck, but because both are tied to the shared LORA_TX_POWER=22 target (RadioLib's default 60mA current limit silently caps real TX power below 22dBm on any board, not something T-Deck-specific), not board-specific analog circuitry. Also fixes a real bug found compiling this for the first time: target.h only ever included the LR1121 wrapper (vendored locally in this variant dir), never the core lib's CustomSX1262Wrapper -- unlike T-Deck/Heltec V4's own target.h, which already include it. RADIO_CLASS/ WRAPPER_CLASS resolved to nothing and every radio_driver.* call site in MyMesh.cpp failed to compile. Hardware-verified: flashed a real SX1262 unit via the documented out-of-box recipe (full erase + merged image), boot log confirmed radio init succeeded alongside the rest of the board, and a live mesh test confirmed send/receive on the public channel, a custom channel, and DM. Signed-off-by: Tesso M Costa --- platformio.ini | 218 +++++++++++++++++++++++++ variants/lilygo_tlora_pager/target.cpp | 16 ++ variants/lilygo_tlora_pager/target.h | 9 +- 3 files changed, 242 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 6e5fd95..d683fc8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -596,3 +596,221 @@ lib_deps = 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 diff --git a/variants/lilygo_tlora_pager/target.cpp b/variants/lilygo_tlora_pager/target.cpp index 2d3571a..99b2009 100644 --- a/variants/lilygo_tlora_pager/target.cpp +++ b/variants/lilygo_tlora_pager/target.cpp @@ -55,6 +55,21 @@ bool radio_init() { // 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 @@ -103,6 +118,7 @@ bool radio_init() { radio.setCRC(1); return true; +#endif } mesh::LocalIdentity radio_new_identity() { diff --git a/variants/lilygo_tlora_pager/target.h b/variants/lilygo_tlora_pager/target.h index 3c66272..1031722 100644 --- a/variants/lilygo_tlora_pager/target.h +++ b/variants/lilygo_tlora_pager/target.h @@ -4,7 +4,14 @@ #define RADIOLIB_STATIC_ONLY 1 #include #include -#include "CustomLR1121Wrapper.h" +#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) From 9881bb003851e9330ce7c833ba5420f75974e4ad Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Mon, 13 Jul 2026 13:15:16 -0600 Subject: [PATCH 37/48] pager: WASD pans the Map tab, move slider nudge to Q/E Reuses mapNudge() (previously Tanmatsu's Ctrl+Arrow-only helper, now shared) so W/A/S/D pan the map north/west/south/east on this board's touch-less Map tab. Frees up D from the slider-nudge binding, which moves to Q/E so the two gestures don't collide when a slider (e.g. the map zoom bar) is focused while WASD panning is in play. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 43 +++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 0b6c159..6f933c6 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -2409,9 +2409,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 @@ -23371,13 +23369,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. @@ -23400,7 +23398,7 @@ static void mapNudge(int dir) { renderMapMarkers(); refreshMapInfoLabel(); } -#endif // HAS_TANMATSU (mapNudge) +#endif // HAS_TANMATSU || TLORA_PAGER (mapNudge) // ----- Zoom + recenter ----- // @@ -28844,17 +28842,32 @@ static void handleHwKey(int key) { // 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. D/F reuse navMoveDir()'s existing slider-capture branch + // 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. - if (key == 'd' || key == 'D' || key == 'f' || key == 'F') { + // 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 == 'd' || key == 'D') ? NAV_RIGHT : NAV_LEFT); + 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 // A field is focused but we're in navigate mode: select/Enter starts editing it, so the From 8e7d6ae90284618e599c1623e64230af6adf6716 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 08:27:05 -0600 Subject: [PATCH 38/48] docs: add T-LoRa Pager keyboard & rotary encoder shortcut guide User-facing reference for MeshCore users new to this board's touch-less input model: keyboard layout, Shift/Caps, accent-variant picker, sliders, map panning, keyboard backlight, and a full gesture quick-reference. Signed-off-by: Tesso M Costa --- TLORA_PAGER_SHORTCUTS.md | 153 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 TLORA_PAGER_SHORTCUTS.md diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md new file mode 100644 index 0000000..dc5f008 --- /dev/null +++ b/TLORA_PAGER_SHORTCUTS.md @@ -0,0 +1,153 @@ +# 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 | +|---|---| +| Turn | Move focus to the next/previous item on screen | +| Short click | Select / confirm the focused item (same as Enter) | +| Hold ~1 s, then release | **Back**: closes a popup → closes an open chat → goes Home → Esc (whichever applies first) | +| **Fn (Alt) + turn**, on a main tab | Jump directly between the 5 main tabs (Chats / Contacts / Home / Map / Settings) | +| **Fn (Alt) + turn**, inside a settings page or chat | Scroll the page up/down | +| Turn, with a dropdown open | Scroll through the dropdown's options | +| Turn, with the accent picker open | Cycle through the accent variants (see below) | + +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**: toggles **Caps Lock** on/off — stays + uppercase until you repeat the chord. +- Shift alone, tapped with nothing else, does nothing (as expected). + +### 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. + +## 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 | Move focus | +| Click encoder | Select / confirm | +| Hold encoder ~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 | Toggle Caps Lock | +| Fn + Space | Enter accent picker | +| 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 | From c4c28d493db06d4fbeb2e18101e081af7783320e Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 08:46:18 -0600 Subject: [PATCH 39/48] pager: rotary encoder short click opens the message action menu A focused chat bubble already opened its Ack/Mention/Copy/Info/Block action menu on Enter (navEnterBubble), but the encoder's short click only sent a plain ENTER, which a bubble doesn't otherwise react to. Mirrors handleHwKey()'s Enter branch so both inputs agree. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 6f933c6..20e7527 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -27604,8 +27604,12 @@ static void updatePagerEncoder(unsigned long now) { pagerNavGoBack(); // see pagerNavGoBack() above for the ladder + rationale s_long_fired = true; } else if (!held && s_was_held && !s_long_fired) { - if (s_accentnav_active) accentNavConfirm(); // picking an accent: confirm the highlighted one - else navPushTap(LV_KEY_ENTER); // released before the long-press threshold -> short click + // 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_accentnav_active) accentNavConfirm(); // picking an accent: confirm the highlighted one + else if (!navEnterBubble()) navPushTap(LV_KEY_ENTER); } s_was_held = held; } @@ -28804,12 +28808,13 @@ static void handleHwKey(int key) { #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 already sends via navPushTap(LV_KEY_ENTER) -- + // 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). + // 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(); From b16206bf7d303b3684bb218a3e03820015b082da Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 08:46:55 -0600 Subject: [PATCH 40/48] pager: Fn+Shift toggles Caps Lock only while editing a field, else jumps Home The Fn(Alt)+Shift chord previously toggled Caps Lock unconditionally, even with no text field focused to see the change reflected in -- reported as surprising and purposeless outside of typing. PagerKeyboard now only reports the chord (pagerKeyboardConsumeAltShiftChord()); the UI decides the effect: Caps Lock toggle while actually editing a field, otherwise a direct jump to the Home screen (mirrors the "ta" derivation handleHwKey() already uses to tell bound-but-unfocused fields apart from one actually being edited). Signed-off-by: Tesso M Costa --- TLORA_PAGER_SHORTCUTS.md | 9 ++++++--- src/helpers/input/PagerKeyboard.cpp | 30 +++++++++++++++++++++-------- src/helpers/input/PagerKeyboard.h | 11 +++++++++++ src/ui-touch/UITask.cpp | 23 ++++++++++++++++++++-- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md index dc5f008..27c4a86 100644 --- a/TLORA_PAGER_SHORTCUTS.md +++ b/TLORA_PAGER_SHORTCUTS.md @@ -62,8 +62,10 @@ Hold **Fn (Alt)** for numbers/symbols instead: - **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**: toggles **Caps Lock** on/off — stays - uppercase until you repeat the chord. +- **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 same chord instead jumps straight + to the **Home** screen. - Shift alone, tapped with nothing else, does nothing (as expected). ### Special keys @@ -141,7 +143,8 @@ dimmable brightness curve. | Fn + turn (page/chat) | Scroll | | Fn tap alone | Next field | | Hold Shift + letter | Momentary uppercase | -| Fn + Shift | Toggle Caps Lock | +| Fn + Shift (editing a field) | Toggle Caps Lock | +| Fn + Shift (not editing a field) | Jump to Home | | Fn + Space | Enter accent picker | | Enter | Select / send / message action menu | | Backspace (tap) | Delete / jump to latest message | diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index 2dbce95..fad94d1 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -38,13 +38,18 @@ static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { // 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 a -// toggle for persistent Caps Lock (s_caps). 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. +// 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, or jump Home 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()). 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. 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) @@ -57,6 +62,7 @@ static bool s_alt_used = false; // Alt consumed as a modifier since it w 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_backspace_held = false; static bool s_space_held = false; @@ -115,7 +121,7 @@ void pagerKeyboardPoll() { if (s_alt && pressed) s_alt_used = true; if (code == kShiftPos) { if (pressed) { - if (s_alt) s_caps = !s_caps; // Alt (Fn) held + Shift press = toggle persistent Caps Lock + 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; @@ -168,4 +174,12 @@ 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; } + #endif diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index 26df431..d726db8 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -69,4 +69,15 @@ bool pagerKeyboardBackspaceHeld(); * "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(); + #endif diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 20e7527..1e76008 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -27360,6 +27360,22 @@ static void updatePagerAltTapNext() { if (g_lv.task) g_lv.task->noteUserInput(); } +// Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has +// no UI visibility): while actually editing a text field, toggle Caps Lock +// (the field is where "Caps Lock" means anything); everywhere else — no field +// focused, or a field merely bound but nav focus has moved off it (same "ta" +// derivation handleHwKey() uses) — jump straight Home instead, since Caps +// Lock silently flipping with no field to see it in was reported as +// surprising/purposeless outside of typing. +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) pagerKeyboardToggleCaps(); + else 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 @@ -38362,9 +38378,11 @@ void UITask::loop() { if (pagerKeyboardReadKey() <= 0) break; any = true; } - // Discard any Alt tap picked up while idle-dimmed -- it must not fire - // updatePagerAltTapNext()'s NEXT the instant the screen wakes. + // Discard any Alt tap / Alt+Shift chord picked up while idle-dimmed -- it + // must not fire updatePagerAltTapNext()'s NEXT / updatePagerAltShiftChord()'s + // toggle-or-Home the instant the screen wakes. pagerKeyboardConsumeAltTap(); + pagerKeyboardConsumeAltShiftChord(); // 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(); @@ -38375,6 +38393,7 @@ void UITask::loop() { handleHwKey(key); } updatePagerAltTapNext(); + updatePagerAltShiftChord(); updatePagerBackspaceHold(now); updatePagerSpaceHold(now); } From 57802eba3de73e2966abebd5a6cc9f5dd0d61b04 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 09:28:29 -0600 Subject: [PATCH 41/48] pager: make the @-mention contact picker encoder/keyboard-navigable Typing "@partial" in a composer popped up a touch-only contact list (NAV_SKIP_FLAG cells, same as the accent-variant box before it got the same treatment) -- on this board's no-touch input model there was no way to actually pick a name from it. Mirrors the accent box's pager nav (rotary encoder walks the highlighted cell, Enter/click confirms, Backspace cancels without touching the typed text), but grabs the encoder the instant the list appears rather than requiring an explicit Fn+Space arm: unlike the accent box (which pops up after almost 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. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 92 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 1e76008..7d1f890 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -5196,10 +5196,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). @@ -5254,6 +5291,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); @@ -5275,6 +5322,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); @@ -5284,6 +5334,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); @@ -27547,7 +27600,18 @@ static void updatePagerEncoder(unsigned long now) { // release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT. if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed(); - if (s_accentnav_active) { + 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. @@ -27624,7 +27688,8 @@ static void updatePagerEncoder(unsigned long now) { // 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_accentnav_active) accentNavConfirm(); // picking an accent: confirm the highlighted one + 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; @@ -28977,12 +29042,23 @@ static void handleHwKey(int key) { } txtMenuHide(); // any keypress while editing dismisses an open edit menu #if defined(TLORA_PAGER) - // Accent-variant popup (issue #22) is otherwise unreachable here: no touch, - // and its cells are NAV_SKIP_FLAG by design (touch boards pick them by tap). - // Fn(Alt)+Space jumps keyboard focus into it; the rotary encoder then walks - // the highlighted variant (updatePagerEncoder()); Enter (the encoder's own - // click) confirms via accentNavConfirm(); Backspace cancels without - // deleting the base letter that triggered the popup. + // 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; } From 6073b6fa4e22a158271750a56fb37a2390c9f9d9 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 09:29:06 -0600 Subject: [PATCH 42/48] ui: app-drawer tiles use their own accent tint for keyboard/encoder focus App tiles previously fell through to navFocusCb's generic white reverse-video fill when focused via keyboard/encoder nav, instead of the accent-tinted LV_STATE_PRESSED look they already show on a touch press -- a solid white block over a small rounded icon chip reads as a glitch, not a highlight, and looked inconsistent with the touch-driven boards. Tiles now opt into the same accent tint for both states. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 7d1f890..4b6c696 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -1101,6 +1101,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 @@ -2629,6 +2640,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; @@ -30576,6 +30596,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 From 2540e230c6e6fe015410fa97b4cf7204857f869e Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 09:29:38 -0600 Subject: [PATCH 43/48] docs: document the @-mention picker's encoder navigation Signed-off-by: Tesso M Costa --- TLORA_PAGER_SHORTCUTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md index 27c4a86..fb35517 100644 --- a/TLORA_PAGER_SHORTCUTS.md +++ b/TLORA_PAGER_SHORTCUTS.md @@ -96,6 +96,18 @@ without touch: 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 @@ -146,6 +158,7 @@ dimmable brightness curve. | Fn + Shift (editing a field) | Toggle Caps Lock | | Fn + Shift (not editing a field) | 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 | From a95f976262322013dcac34ed36a005f2f23b769c Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 09:43:26 -0600 Subject: [PATCH 44/48] docs: note keyboard alternatives for the encoder's turn/click/hold gestures Signed-off-by: Tesso M Costa --- TLORA_PAGER_SHORTCUTS.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md index fb35517..27e225d 100644 --- a/TLORA_PAGER_SHORTCUTS.md +++ b/TLORA_PAGER_SHORTCUTS.md @@ -23,15 +23,16 @@ normally and these combos step out of the way. ## Rotary encoder -| Gesture | Action | -|---|---| -| Turn | Move focus to the next/previous item on screen | -| Short click | Select / confirm the focused item (same as Enter) | -| Hold ~1 s, then release | **Back**: closes a popup → closes an open chat → goes Home → Esc (whichever applies first) | -| **Fn (Alt) + turn**, on a main tab | Jump directly between the 5 main tabs (Chats / Contacts / Home / Map / Settings) | -| **Fn (Alt) + turn**, inside a settings page or chat | Scroll the page up/down | -| Turn, with a dropdown open | Scroll through the dropdown's options | -| Turn, with the accent picker open | Cycle through the accent variants (see below) | +| 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 @@ -148,9 +149,9 @@ dimmable brightness curve. | Input | Action | |---|---| -| Turn encoder | Move focus | -| Click encoder | Select / confirm | -| Hold encoder ~1s | Back | +| 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 | From 01833d0365ad6258be36900baf555c669f7c3beb Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 09:44:42 -0600 Subject: [PATCH 45/48] docs: track T-LoRa Pager port tracker in git 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 --- TLORA_PAGER_PORT.md | 996 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 996 insertions(+) create mode 100644 TLORA_PAGER_PORT.md 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`. From e5cd766f73f4f0359ee7ffda44e8511685b3f0b2 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 12:15:29 -0600 Subject: [PATCH 46/48] pager: move Home-jump to Alt+Backspace, works everywhere including mid-edit Alt+Shift previously did double duty: toggle Caps Lock while editing a field, or jump Home otherwise. Splitting these apart -- Alt+Backspace is now the dedicated Home-jump gesture, and unlike Alt+Shift it isn't context-dependent: it works whether or not a field is being edited, so it's reachable from anywhere without first backing out of what you're typing. Alt+Shift outside a field is now a no-op instead. PagerKeyboard.cpp suppresses a Backspace press entirely while Alt is held (no '\b' ring-push, no hold-to-back/hold-to-unlock tracking) and reports it as a one-shot chord instead, mirroring the existing Alt+Shift chord plumbing. Also closes the accent/@-mention popups first, since those live on lv_layer_top() outside the tab content the Home jump switches away from. Signed-off-by: Tesso M Costa --- TLORA_PAGER_SHORTCUTS.md | 10 +++++-- src/helpers/input/PagerKeyboard.cpp | 45 ++++++++++++++++++++++------- src/helpers/input/PagerKeyboard.h | 18 +++++++++--- src/ui-touch/UITask.cpp | 41 +++++++++++++++++++------- 4 files changed, 86 insertions(+), 28 deletions(-) diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md index 27e225d..031126a 100644 --- a/TLORA_PAGER_SHORTCUTS.md +++ b/TLORA_PAGER_SHORTCUTS.md @@ -65,9 +65,12 @@ Hold **Fn (Alt)** for numbers/symbols instead: 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 same chord instead jumps straight - to the **Home** screen. + 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 @@ -157,7 +160,8 @@ dimmable brightness curve. | Fn tap alone | Next field | | Hold Shift + letter | Momentary uppercase | | Fn + Shift (editing a field) | Toggle Caps Lock | -| Fn + Shift (not editing a field) | Jump to Home | +| 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 | diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index fad94d1..e357368 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -41,15 +41,21 @@ static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { // 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, or jump Home 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()). 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. +// 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) @@ -63,6 +69,7 @@ static bool s_alt_tap_pending = false; // Alt pressed+released with nothing els 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; @@ -128,7 +135,19 @@ void pagerKeyboardPoll() { } continue; } - if (code == kBackspacePos) { s_backspace_held = pressed; if (pressed) ringPush('\b'); 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 @@ -182,4 +201,10 @@ bool pagerKeyboardConsumeAltShiftChord() { 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 index d726db8..774e552 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -56,10 +56,12 @@ void pagerKeyboardMarkAltUsed(); * symbol-layer or Alt+turn hold. Consumes the pending flag on read. */ bool pagerKeyboardConsumeAltTap(); -/** True while Backspace is physically held (raw state, mirrors - * pagerKeyboardAltHeld()). A 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). */ +/** 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 @@ -80,4 +82,12 @@ bool pagerKeyboardConsumeAltShiftChord(); * 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/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 2ac5013..932fe82 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -31177,18 +31177,35 @@ static void updatePagerAltTapNext() { } // Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has -// no UI visibility): while actually editing a text field, toggle Caps Lock -// (the field is where "Caps Lock" means anything); everywhere else — no field -// focused, or a field merely bound but nav focus has moved off it (same "ta" -// derivation handleHwKey() uses) — jump straight Home instead, since Caps -// Lock silently flipping with no field to see it in was reported as -// surprising/purposeless outside of typing. +// 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) pagerKeyboardToggleCaps(); - else navGoToMainTab(HOME_TAB_INDEX); + 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(); } @@ -42834,11 +42851,12 @@ void UITask::loop() { if (pagerKeyboardReadKey() <= 0) break; any = true; } - // Discard any Alt tap / Alt+Shift chord picked up while idle-dimmed -- it - // must not fire updatePagerAltTapNext()'s NEXT / updatePagerAltShiftChord()'s - // toggle-or-Home the instant the screen wakes. + // 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(); @@ -42850,6 +42868,7 @@ void UITask::loop() { } updatePagerAltTapNext(); updatePagerAltShiftChord(); + updatePagerAltBackspaceChord(); updatePagerBackspaceHold(now); updatePagerSpaceHold(now); } From 53a60dfbb2d54ff11421d95849f480b467f96738 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 17:54:55 -0600 Subject: [PATCH 47/48] pager: hide the touch-only chat jump-to-oldest/latest arrows Both floating jump arrows in the open-chat view are touch-only affordances with no purpose on the T-LoRa Pager (no touchscreen), so skip creating them on that board and leave the pointers null like every other consumer already expects. The pager's existing Backspace-tap "jump to latest" shortcut no longer depends on the button object itself -- it checks chatVirtAwayFromBottom() directly and calls chatVirtJumpToLatest(), the same virtualization-aware jump the button's own click handler used. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 45 +++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 932fe82..c73fcfb 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26438,6 +26438,14 @@ static void makeChatDetail(LvChatPanel& p) { // transparent button keeps a usable touch target (plus ext_click_area) while // only the glyph is visible; UITask::loop dims them to 50% one second after // the last scroll (jumpBtnsSetDim above). + // "Jump to oldest" (up arrow) skipped on the pager only: no touch to tap it, + // and unlike jump_btn (down arrow) it has no keyboard-driven alternative + // today, so it's just dead clutter on the right edge of the chat rather than + // a feature this board would lose — left null, which every consumer of + // jump_oldest_btn already handles safely (all null-guarded). jump_btn stays + // exactly as-is (its Backspace-tap alternative, handleHwKey()'s TLORA_PAGER + // branch, still relies on it). +#if !defined(TLORA_PAGER) p.jump_oldest_btn = lv_btn_create(p.overlay); lv_obj_set_size(p.jump_oldest_btn, 28, 36); lv_obj_set_style_bg_opa(p.jump_oldest_btn, LV_OPA_TRANSP, LV_PART_MAIN); @@ -26462,7 +26470,15 @@ static void makeChatDetail(LvChatPanel& p) { #endif lv_obj_add_event_cb(p.jump_oldest_btn, jumpToOldestCb, LV_EVENT_CLICKED, &p); lv_obj_add_flag(p.jump_oldest_btn, LV_OBJ_FLAG_HIDDEN); +#endif // !TLORA_PAGER (jump_oldest_btn) + // "Jump to latest" (down arrow) skipped on the pager too: no touch to tap + // it, and its keyboard-driven alternative (a Backspace tap, handleHwKey()'s + // TLORA_PAGER branch) doesn't need the button object itself -- it's updated + // below to check chatVirtAwayFromBottom() directly instead of reading this + // (now pager-absent) button's hidden-state. Left null, same as + // jump_oldest_btn above -- every consumer already null-guards it. +#if !defined(TLORA_PAGER) p.jump_btn = lv_btn_create(p.overlay); lv_obj_set_size(p.jump_btn, 28, 36); lv_obj_set_style_bg_opa(p.jump_btn, LV_OPA_TRANSP, LV_PART_MAIN); @@ -26488,6 +26504,7 @@ static void makeChatDetail(LvChatPanel& p) { #endif lv_obj_add_event_cb(p.jump_btn, jumpToLatestCb, LV_EVENT_CLICKED, &p); lv_obj_add_flag(p.jump_btn, LV_OBJ_FLAG_HIDDEN); +#endif // !TLORA_PAGER (jump_btn) // ---- Composer row ---- p.composer_row = lv_obj_create(p.overlay); @@ -32822,21 +32839,23 @@ if (g_lv.task && g_lv.task->isManualLock()) { 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. + // bottom" circle other boards get (it isn't even created here, see + // makeChatDetail()'s TLORA_PAGER carve-out), so a plain Backspace tap + // does the same jump the T-Deck's own jumpToLatestCb() does on click + // (mirrors the Tanmatsu F6 hardware-key handler above). Checks + // chatVirtAwayFromBottom() directly -- the exact predicate the (absent) + // button's own visibility would have used -- instead of reading a button + // that no longer exists on this board. Only fires while actually away + // from the newest message, 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 && cp->msgs && chatVirtAwayFromBottom(cp)) { + chatVirtJumpToLatest(cp); if (cp->composer_ta && lv_obj_is_valid(cp->composer_ta)) { lv_group_focus_obj(cp->composer_ta); s_nav_show = true; From 0adcd26b78af5558452a91f9dfab13cc61d49360 Mon Sep 17 00:00:00 2001 From: Tesso M Costa Date: Tue, 14 Jul 2026 21:52:04 -0600 Subject: [PATCH 48/48] Revert "pager: hide the touch-only chat jump-to-oldest/latest arrows" This reverts commit 53a60dfbb2d54ff11421d95849f480b467f96738. Signed-off-by: Tesso M Costa --- src/ui-touch/UITask.cpp | 45 ++++++++++++----------------------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index c73fcfb..932fe82 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -26438,14 +26438,6 @@ static void makeChatDetail(LvChatPanel& p) { // transparent button keeps a usable touch target (plus ext_click_area) while // only the glyph is visible; UITask::loop dims them to 50% one second after // the last scroll (jumpBtnsSetDim above). - // "Jump to oldest" (up arrow) skipped on the pager only: no touch to tap it, - // and unlike jump_btn (down arrow) it has no keyboard-driven alternative - // today, so it's just dead clutter on the right edge of the chat rather than - // a feature this board would lose — left null, which every consumer of - // jump_oldest_btn already handles safely (all null-guarded). jump_btn stays - // exactly as-is (its Backspace-tap alternative, handleHwKey()'s TLORA_PAGER - // branch, still relies on it). -#if !defined(TLORA_PAGER) p.jump_oldest_btn = lv_btn_create(p.overlay); lv_obj_set_size(p.jump_oldest_btn, 28, 36); lv_obj_set_style_bg_opa(p.jump_oldest_btn, LV_OPA_TRANSP, LV_PART_MAIN); @@ -26470,15 +26462,7 @@ static void makeChatDetail(LvChatPanel& p) { #endif lv_obj_add_event_cb(p.jump_oldest_btn, jumpToOldestCb, LV_EVENT_CLICKED, &p); lv_obj_add_flag(p.jump_oldest_btn, LV_OBJ_FLAG_HIDDEN); -#endif // !TLORA_PAGER (jump_oldest_btn) - // "Jump to latest" (down arrow) skipped on the pager too: no touch to tap - // it, and its keyboard-driven alternative (a Backspace tap, handleHwKey()'s - // TLORA_PAGER branch) doesn't need the button object itself -- it's updated - // below to check chatVirtAwayFromBottom() directly instead of reading this - // (now pager-absent) button's hidden-state. Left null, same as - // jump_oldest_btn above -- every consumer already null-guards it. -#if !defined(TLORA_PAGER) p.jump_btn = lv_btn_create(p.overlay); lv_obj_set_size(p.jump_btn, 28, 36); lv_obj_set_style_bg_opa(p.jump_btn, LV_OPA_TRANSP, LV_PART_MAIN); @@ -26504,7 +26488,6 @@ static void makeChatDetail(LvChatPanel& p) { #endif lv_obj_add_event_cb(p.jump_btn, jumpToLatestCb, LV_EVENT_CLICKED, &p); lv_obj_add_flag(p.jump_btn, LV_OBJ_FLAG_HIDDEN); -#endif // !TLORA_PAGER (jump_btn) // ---- Composer row ---- p.composer_row = lv_obj_create(p.overlay); @@ -32839,23 +32822,21 @@ if (g_lv.task && g_lv.task->isManualLock()) { return; } // Jump to latest: this board has no touch to tap the floating "scroll to - // bottom" circle other boards get (it isn't even created here, see - // makeChatDetail()'s TLORA_PAGER carve-out), so a plain Backspace tap - // does the same jump the T-Deck's own jumpToLatestCb() does on click - // (mirrors the Tanmatsu F6 hardware-key handler above). Checks - // chatVirtAwayFromBottom() directly -- the exact predicate the (absent) - // button's own visibility would have used -- instead of reading a button - // that no longer exists on this board. Only fires while actually away - // from the newest message, 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. + // 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 && chatVirtAwayFromBottom(cp)) { - chatVirtJumpToLatest(cp); + 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;