Refine mono UI GPS pages and board diagnostics

This commit is contained in:
liu weikai
2026-03-20 02:07:55 +08:00
parent 9ade3e9a85
commit 9cf12df3e8
5 changed files with 1051 additions and 350 deletions
@@ -26,7 +26,6 @@ const char kProbeSymbols[] = "\xE2\x94\x80\xE2\x96\x88\xE2\x96\xA0";
uint32_t now_ms() { return millis(); }
time_t utc_now() { return static_cast<time_t>(sys::epoch_seconds_now()); }
void mono_ui_debug_log(const char* text) { debug_console::print(text ? text : ""); }
uint32_t active_lora_frequency_hz()
{
@@ -59,38 +58,6 @@ ui::mono_128x64::InputAction to_input_action(
}
}
const char* board_key_name(BoardInputKey key)
{
switch (key)
{
case BoardInputKey::JoystickUp: return "JoyUp";
case BoardInputKey::JoystickDown: return "JoyDown";
case BoardInputKey::JoystickLeft: return "JoyLeft";
case BoardInputKey::JoystickRight: return "JoyRight";
case BoardInputKey::JoystickPress: return "JoyPress";
case BoardInputKey::PrimaryButton: return "Primary";
case BoardInputKey::SecondaryButton: return "Secondary";
default: return "?";
}
}
const char* input_action_name(ui::mono_128x64::InputAction action)
{
switch (action)
{
case ui::mono_128x64::InputAction::None: return "None";
case ui::mono_128x64::InputAction::Up: return "Up";
case ui::mono_128x64::InputAction::Down: return "Down";
case ui::mono_128x64::InputAction::Left: return "Left";
case ui::mono_128x64::InputAction::Right: return "Right";
case ui::mono_128x64::InputAction::Select: return "Select";
case ui::mono_128x64::InputAction::Back: return "Back";
case ui::mono_128x64::InputAction::Primary: return "Primary";
case ui::mono_128x64::InputAction::Secondary: return "Secondary";
default: return "?";
}
}
bool s_initialized = false;
ui::mono_128x64::Runtime* s_runtime = nullptr;
bool s_probe_drawn = false;
@@ -151,7 +118,6 @@ bool initialize()
callbacks.gps_data_fn = platform::ui::gps::get_data;
callbacks.gps_enabled_fn = platform::ui::gps::is_enabled;
callbacks.gps_powered_fn = platform::ui::gps::is_powered;
callbacks.debug_log_fn = mono_ui_debug_log;
static ui::mono_128x64::Runtime runtime(::boards::gat562_mesh_evb_pro::Gat562Board::instance().monoDisplay(),
callbacks);
@@ -183,13 +149,6 @@ void tick(const BoardInputEvent* event)
if (initialize() && s_runtime)
{
const auto action = to_input_action(event);
if (event && event->pressed)
{
debug_console::printf("[gat562][ui] input key=%s pressed=%u action=%s\n",
board_key_name(event->key),
static_cast<unsigned>(event->pressed ? 1 : 0),
input_action_name(action));
}
s_runtime->tick(action);
}
}
+128 -30
View File
@@ -53,8 +53,6 @@ struct InputRuntimeState
{
uint32_t last_activity_ms = 0;
BoardInputSnapshot snapshot{};
BoardInputSnapshot logged_snapshot{};
bool has_logged_snapshot = false;
DebounceState button_primary{};
DebounceState button_secondary{};
DebounceState joystick_up{};
@@ -64,33 +62,6 @@ struct InputRuntimeState
DebounceState joystick_press{};
} s_input;
void logInputSnapshotChange(const BoardInputSnapshot& snapshot)
{
if (s_input.has_logged_snapshot &&
s_input.logged_snapshot.button_primary == snapshot.button_primary &&
s_input.logged_snapshot.button_secondary == snapshot.button_secondary &&
s_input.logged_snapshot.joystick_up == snapshot.joystick_up &&
s_input.logged_snapshot.joystick_down == snapshot.joystick_down &&
s_input.logged_snapshot.joystick_left == snapshot.joystick_left &&
s_input.logged_snapshot.joystick_right == snapshot.joystick_right &&
s_input.logged_snapshot.joystick_press == snapshot.joystick_press)
{
return;
}
s_input.logged_snapshot = snapshot;
s_input.has_logged_snapshot = true;
Serial.printf("[gat562][board] raw in pri=%u sec=%u up=%u down=%u left=%u right=%u press=%u any=%u\n",
static_cast<unsigned>(snapshot.button_primary ? 1 : 0),
static_cast<unsigned>(snapshot.button_secondary ? 1 : 0),
static_cast<unsigned>(snapshot.joystick_up ? 1 : 0),
static_cast<unsigned>(snapshot.joystick_down ? 1 : 0),
static_cast<unsigned>(snapshot.joystick_left ? 1 : 0),
static_cast<unsigned>(snapshot.joystick_right ? 1 : 0),
static_cast<unsigned>(snapshot.joystick_press ? 1 : 0),
static_cast<unsigned>(snapshot.any_activity ? 1 : 0));
}
struct GpsRuntimeState
{
TinyGPSPlus parser{};
@@ -108,6 +79,9 @@ struct GpsRuntimeState
uint32_t epoch_base_s = 0;
uint32_t epoch_base_ms = 0;
uint32_t last_nmea_ms = 0;
uint32_t last_time_sync_log_ms = 0;
uint32_t last_time_sync_epoch_logged = 0;
uint32_t last_status_log_ms = 0;
bool enabled = true;
bool powered = false;
bool initialized = false;
@@ -259,12 +233,119 @@ void applyGpsTimeIfValid()
{
return;
}
const uint32_t prev_epoch_s = s_gps.epoch_base_s;
s_gps.epoch_base_s = utc_s;
s_gps.epoch_base_ms = millis();
s_gps.time_synced = true;
if (s_gps.last_time_sync_epoch_logged != utc_s ||
(s_gps.epoch_base_ms - s_gps.last_time_sync_log_ms) >= 1000U)
{
s_gps.last_time_sync_epoch_logged = utc_s;
s_gps.last_time_sync_log_ms = s_gps.epoch_base_ms;
Serial.printf(
"[gat562][gps] time sync source=gnss epoch=%lu prev=%lu sats=%u fix=%u age_ms=%lu date=%04u-%02u-%02u time=%02u:%02u:%02u\n",
static_cast<unsigned long>(utc_s),
static_cast<unsigned long>(prev_epoch_s),
static_cast<unsigned>(s_gps.parser.satellites.isValid() ? s_gps.parser.satellites.value() : 0U),
static_cast<unsigned>(s_gps.parser.location.isValid() ? 1U : 0U),
static_cast<unsigned long>(s_gps.parser.location.isValid() ? s_gps.parser.location.age() : 0U),
static_cast<unsigned>(year),
static_cast<unsigned>(month),
static_cast<unsigned>(day),
static_cast<unsigned>(hour),
static_cast<unsigned>(minute),
static_cast<unsigned>(second));
}
syncSystemClockFromEpoch(utc_s);
}
void logGpsStatusIfDue()
{
if (!s_gps.initialized || !s_gps.enabled)
{
return;
}
const uint32_t now_ms = millis();
const uint32_t interval_ms = s_gps.collection_interval_ms > 0 ? s_gps.collection_interval_ms : 60000U;
if ((now_ms - s_gps.last_status_log_ms) < interval_ms)
{
return;
}
s_gps.last_status_log_ms = now_ms;
const bool time_valid = s_gps.parser.time.isValid();
const bool date_valid = s_gps.parser.date.isValid();
const bool fix_valid = s_gps.parser.location.isValid();
const uint16_t year = date_valid ? s_gps.parser.date.year() : 0U;
const uint8_t month = date_valid ? s_gps.parser.date.month() : 0U;
const uint8_t day = date_valid ? s_gps.parser.date.day() : 0U;
const uint8_t hour = time_valid ? s_gps.parser.time.hour() : 0U;
const uint8_t minute = time_valid ? s_gps.parser.time.minute() : 0U;
const uint8_t second = time_valid ? s_gps.parser.time.second() : 0U;
const bool datetime_shape_valid = time_valid && date_valid && gpsDateTimeValid(year, month, day, hour, minute, second);
const time_t utc = datetime_shape_valid ? gpsDateTimeToEpochUtc(year, month, day, hour, minute, second)
: static_cast<time_t>(0);
const bool epoch_ok = utc >= static_cast<time_t>(kMinValidEpochSeconds);
const uint32_t sat_count = s_gps.parser.satellites.isValid() ? s_gps.parser.satellites.value() : 0U;
const uint32_t nmea_age_ms = s_gps.last_nmea_ms > 0 ? (now_ms - s_gps.last_nmea_ms) : 0U;
const char* state = "idle";
if (!s_gps.nmea_seen)
{
state = "no_nmea";
}
else if (!time_valid || !date_valid)
{
state = "time_invalid";
}
else if (!datetime_shape_valid)
{
state = "datetime_reject";
}
else if (!epoch_ok)
{
state = "epoch_reject";
}
else if (sat_count == 0U)
{
state = "time_only";
}
else if (!fix_valid)
{
state = "search_fix";
}
else if (!s_gps.time_synced)
{
state = "ready_unsynced";
}
else
{
state = "synced";
}
Serial.printf(
"[gat562][gps] status state=%s enabled=%u powered=%u nmea=%u nmea_age_ms=%lu time=%u date=%u fix=%u sats=%u lat=%.6f lng=%.6f epoch=%lu utc=%lu dt=%04u-%02u-%02uT%02u:%02u:%02u\n",
state,
static_cast<unsigned>(s_gps.enabled ? 1 : 0),
static_cast<unsigned>(s_gps.powered ? 1 : 0),
static_cast<unsigned>(s_gps.nmea_seen ? 1 : 0),
static_cast<unsigned long>(nmea_age_ms),
static_cast<unsigned>(time_valid ? 1 : 0),
static_cast<unsigned>(date_valid ? 1 : 0),
static_cast<unsigned>(fix_valid ? 1 : 0),
static_cast<unsigned>(sat_count),
fix_valid ? s_gps.parser.location.lat() : 0.0,
fix_valid ? s_gps.parser.location.lng() : 0.0,
static_cast<unsigned long>(s_gps.epoch_base_s),
static_cast<unsigned long>(epoch_ok ? static_cast<uint32_t>(utc) : 0U),
static_cast<unsigned>(year),
static_cast<unsigned>(month),
static_cast<unsigned>(day),
static_cast<unsigned>(hour),
static_cast<unsigned>(minute),
static_cast<unsigned>(second));
}
void refreshGpsFix()
{
s_gps.data.valid = s_gps.parser.location.isValid();
@@ -606,7 +687,6 @@ bool Gat562Board::pollInputEvent(BoardInputEvent* out_event)
BoardInputSnapshot current{};
(void)pollInputSnapshot(&current);
s_input.snapshot = current;
logInputSnapshotChange(current);
const uint32_t now_ms = millis();
const uint16_t debounce_ms = inputDebounceMs();
@@ -852,6 +932,17 @@ void Gat562Board::applyGpsConfig(const app::AppConfig& config)
s_gps.motion_idle_timeout_ms = config.motion_config.idle_timeout_ms;
s_gps.motion_sensor_id = config.motion_config.sensor_id;
s_gps.enabled = true;
Serial.printf(
"[gat562][gps] config enabled=%u interval_ms=%lu strategy=%u mode=%u sat_mask=0x%02X nmea_hz=%u nmea_mask=0x%02X motion_idle_ms=%lu motion_sensor=%u\n",
static_cast<unsigned>(s_gps.enabled ? 1 : 0),
static_cast<unsigned long>(s_gps.collection_interval_ms),
static_cast<unsigned>(s_gps.power_strategy),
static_cast<unsigned>(s_gps.gnss_mode),
static_cast<unsigned>(s_gps.sat_mask),
static_cast<unsigned>(s_gps.nmea_output_hz),
static_cast<unsigned>(s_gps.nmea_sentence_mask),
static_cast<unsigned long>(s_gps.motion_idle_timeout_ms),
static_cast<unsigned>(s_gps.motion_sensor_id));
}
void Gat562Board::tickGps()
@@ -869,6 +960,7 @@ void Gat562Board::tickGps()
}
applyGpsTimeIfValid();
refreshGpsFix();
logGpsStatusIfDue();
}
bool Gat562Board::isGpsRuntimeReady() const
@@ -947,9 +1039,15 @@ void Gat562Board::setCurrentEpochSeconds(uint32_t epoch_s)
return;
}
const uint32_t prev_epoch_s = s_gps.epoch_base_s;
s_gps.epoch_base_s = epoch_s;
s_gps.epoch_base_ms = millis();
s_gps.time_synced = true;
s_gps.last_time_sync_epoch_logged = epoch_s;
s_gps.last_time_sync_log_ms = s_gps.epoch_base_ms;
Serial.printf("[gat562][gps] time sync source=external epoch=%lu prev=%lu\n",
static_cast<unsigned long>(epoch_s),
static_cast<unsigned long>(prev_epoch_s));
syncSystemClockFromEpoch(epoch_s);
}
+251 -234
View File
@@ -1,20 +1,41 @@
<div align="center" markdown="1">
<img src="../../.github/LilyGo_logo.png" alt="LilyGo logo" width="100"/>
</div>
# LilyGo T-LoRa Pager
<h1 align = "center">🌟LilyGo T-LoRa-Pager🌟</h1>
This document records the board-level hardware facts currently used by this
repository for `LilyGo T-LoRa Pager`.
It exists to prevent drift between:
## `1` Overview
- the real LilyGo T-LoRa Pager hardware
- this repository's PlatformIO board / variant / environment definitions
- the ESP board runtime implementation that actually brings the hardware up
* This page introduces the hardware parameters related to `LilyGO T-LoRa-Pager`
## Summary
```bash
- MCU: `ESP32-S3`
- Flash / PSRAM: `16MB QSPI flash + 8MB QSPI PSRAM`
- Display: `ST7796` SPI TFT
- UI resolution used by this repo: `480x222`
- Radio: `SX1262` or `SX1280` depending on build environment
- GNSS: `u-blox MIA-M10Q`
- Input: `rotary encoder + center key + I2C keyboard`
- Power / battery: `BQ25896 + BQ27220`
- RTC: `PCF85063`
- NFC: `ST25R3916`
- Motion sensor: `BHI260AP`
- Audio codec: `ES8311`
- GPIO expander: `XL9555`
## Physical Layout
The original vendor overview included a useful front-panel sketch. It is kept
here because it helps quickly identify the visible controls and connectors when
working with real hardware:
```text
/---------------------------------------------------\
| ┌───────────────────────────────────────────┐ |-| |
| | | |/| |
| | 480 x 222 IPS | |/| |
| | 480 x 222 IPS | |/| |
| | | |/| |
| └───────────────────────────────────────────┘ |-| |
| |
@@ -24,265 +45,261 @@
\---|RST|--|BOOT|--|POWER|--|SD SOCKET|--|USB-C|----/
^ ^ ^ ^ ^
| | | | |
| | | | └─── The adapter is used as a charging and
| | | | programming interface, and the USB-C can
| | | | └─── The adapter is used as a charging and
| | | | programming interface, and the USB-C can
| | | | be programmed to power external devices
| | | |
| | | |
| | | └────── Supports up to 32 GB SD memory card
| | |
| | └───────────── The power button is only valid when the device is
| | |
| | └───────────── The power button is only valid when the device is
| | turned off and cannot be customized or program controlled.
| |
| └───────────────────── (GPIO0) Custom Button or Enter download Mode
|
└───────────────────────────── Click to reset the device,
└───────────────────────────── Click to reset the device,
it cannot be programmed or controlled by the program
```
### Extension interface
## Board Ownership
```bash
Primary board definition files:
>----------Place the screen facing up---------------<
|---------------------------------------------------|
| | SCL | SDA | MISO | SCK | TX | GND | |
| | 5V | CE | GPIO9 | MOSI | RX | 3.3V | |
|---------------------------------------------------|
- [boards/lilygo-t-lora-pager.json](/C:/Users/VicLi/Documents/Projects/trail-mate/boards/lilygo-t-lora-pager.json)
- [pins_arduino.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h)
- [tlora_pager.ini](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/envs/tlora_pager.ini)
- [TLoRaPagerBoard.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/platform/esp/boards/src/board/TLoRaPagerBoard.cpp)
- [TLoRaPagerBoard.h](/C:/Users/VicLi/Documents/Projects/trail-mate/platform/esp/boards/include/board/TLoRaPagerBoard.h)
* CE is XL9555 GPIO9
* TX is ESP32-S3 GPIO43
* RX is ESP32-S3 GPIO44
* MISO is ESP32-S3 GPIO33
* MOSI is ESP32-S3 GPIO34
* SCK is ESP32-S3 GPIO35
* SDA is ESP32-S3 GPIO3
* SCL is ESP32-S3 GPIO2
Rules:
```
- pin truth belongs in `variants/lilygo_tlora_pager/pins_arduino.h`
- board bring-up behavior belongs in `platform/esp/boards/src/board/TLoRaPagerBoard.cpp`
- environment-specific radio and display choices belong in `variants/lilygo_tlora_pager/envs/tlora_pager.ini`
- device docs should reflect what this repository actually builds, not just vendor marketing material
### nRF24L01 PA Shield interface
## Important Boundary
```bash
>----------Place the screen facing up---------------<
|---------------------------------------------------|
| | SCL | SDA | MISO | SCK | TX | GND | |
| | 5V | CE | GPIO9 | MOSI | RX | 3.3V | |
|---------------------------------------------------|
This repository uses the LilyGo Pager as an ESP board with its own runtime
implementation in [TLoRaPagerBoard.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/platform/esp/boards/src/board/TLoRaPagerBoard.cpp).
* CE is XL9555 GPIO9 , nRF24L01 Shield Tx/Rx Control, LOW:Rx HIGH:Tx
* TX is ESP32-S3 GPIO43, nRF24L01 Shield CE Pin
* RX is ESP32-S3 GPIO44, nRF24L01 Shield CS Pin
* MISO is ESP32-S3 GPIO33, nRF24L01 Shield MISO Pin
* MOSI is ESP32-S3 GPIO34, nRF24L01 Shield MOSI Pin
* SCK is ESP32-S3 GPIO35, nRF24L01 Shield SCK Pin
* SDA is ESP32-S3 GPIO3, nRF24L01 Shield No Connect
* SCL is ESP32-S3 GPIO2, nRF24L01 Shield No Connect
That means the most authoritative sources for day-to-day maintenance are:
```
- `pins_arduino.h` for GPIO ownership
- `tlora_pager.ini` for enabled features per environment
- `TLoRaPagerBoard.cpp` for initialization order and power sequencing
### ✨ Hardware-Features
If external vendor docs disagree with runtime behavior here, prefer the checked-in
board runtime unless real hardware verification proves otherwise.
| Features | Params |
| -------------------------------- | -------------------------------- |
| SOC | [Espressif ESP32-S3][1] |
| Flash | 16MB(QSPI) |
| PSRAM | 8MB (QSPI) |
| GNSS | [UBlox MIA-M10Q][2] |
| LoRa | [Semtech SX1262][3] |
| NFC | [ST25R3916][4] |
| Smart sensor | [Bosch BHI260AP][5] |
| Real-Time Clock | [NXP PCF85063A][6] |
| Battery Charger | [Ti BQ25896][7] |
| Battery Gauge | [Ti BQ27220][8] |
| Haptic driver | [Ti DRV2605][9] |
| Audio Codec | [Everest-semi ES8311][10] |
| GPIO Expand | [XINLUDA XL9555][11] |
| I2C Keyboard | [Ti TCA8418][12] |
| Audio Power Amplifier | [Nsiway NS4150B(3W Class D)][13] |
| Display Backlight Driver | [AW9364 16-Level Led Driver][14] |
| SD Card Socket | ✅️ Maximum 32GB (FAT32 format) |
| External low speed clock crystal | ✅️ |
## Build Environments
> \[!TIP]
>
> * SD card only supports FAT format, please pay attention to the selection of SD format
> * Device shutdown can only shut down the device when no USB is connected.
> * The PWR button can only be used to wake up the device by pressing it for one second when the device is turned off. It cannot be used for programming.
> * ST25R3916 (NFC) does not have an integrated capacitive sensor, which means that to read a card, the reader must be turned on, and the presence of a card cannot be detected by turning on the capacitive sensor.
> * ESP32-S3 uses an external QSPI Flash and PSRAM solution, not a built-in PSRAM or Flash solution
> * USB/charging state is detected via the BQ25896 PMU (VBUS/charge status), so UI charging indicators and software shutdown checks rely on PMU detection.
Defined in [tlora_pager.ini](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/envs/tlora_pager.ini):
[1]: https://www.espressif.com.cn/en/products/socs/esp32-s3 "ESP32-S3"
[2]: https://www.u-blox.com/en/product/mia-m10-series "UBlox MIA-M10Q"
[3]: https://www.semtech.com/products/wireless-rf/lora-connect/sx1262 "Semtech SX1262"
[4]: https://www.st.com/en/nfc/st25r3916.html "ST25R3916"
[5]: https://www.bosch-sensortec.com/products/smart-sensor-systems/bhi260ab "BHI260AP"
[6]: https://www.nxp.com/products/PCF85063A "PCF85063A"
[7]: https://www.ti.com/product/BQ25896 "BQ25896"
[8]: https://www.ti.com/product/BQ27220 "BQ27220"
[9]: https://www.ti.com/product/DRV2605 "DRV2605"
[10]: http://www.everest-semi.com/pdf/ES8311%20PB.pdf "ES8311"
[11]: https://www.xinluda.com/en/I2C-to-GPIO-extension/ "XL9555"
[12]: https://www.ti.com/product/TCA8418 "TCA8418"
[13]: http://www.nsiway.com.cn/product/58.html "NS4150B"
[14]: https://item.szlcsc.com/datasheet/AW9364DNR/385721.html "AW9364"
- `tlora_pager_sx1262`
- `tlora_pager_sx1262_debug`
- `tlora_pager_sx1280`
- `tlora_pager_sx1280_debug`
### ✨ Display-Features
Current build-time facts:
| Features | Params |
| --------------------- | ------------- |
| Resolution | 480 x 222 |
| Display Size | 2.33 Inch |
| Luminance on surface | 450 cd/m² |
| Driver IC | ST7796U (SPI) |
| Contrast ratio | 1000:1 |
| Color gamut | 70% |
| PPI | 221 |
| Display Colors | 262K |
| View Direction | All (IPS) |
| Operating Temperature | -2070°C |
- all Pager environments define `ARDUINO_T_LORA_PAGER`
- `SX1262` builds define `ARDUINO_LILYGO_LORA_SX1262`
- `SX1280` builds define `ARDUINO_LILYGO_LORA_SX1280`
- the display driver is built as `ST7796`
- this repo currently builds the Pager UI with `SCREEN_WIDTH=480` and `SCREEN_HEIGHT=222`
### 📍 [Pins Map](https://github.com/espressif/arduino-esp32/blob/master/variants/lilygo_tlora_pager/pins_arduino.h)
## Verified Pin Map
| Name | GPIO NUM | Free |
| ------------------------------------ | ------------------------------ | ---- |
| Custom Pin | GPIO9 (External 12-Pin socket) | ✅️ |
| Uart1 TX | 43(External 12-Pin socket) | ✅️ |
| Uart1 RX | 44(External 12-Pin socket) | ✅️ |
| SDA | 3 | ❌ |
| SCL | 2 | ❌ |
| SPI MOSI | 34 | ❌ |
| SPI MISO | 33 | ❌ |
| SPI SCK | 35 | ❌ |
| SD CS | 21 | ❌ |
| SD MOSI | Share with SPI bus | ❌ |
| SD MISO | Share with SPI bus | ❌ |
| SD SCK | Share with SPI bus | ❌ |
| Keyboard(**TCA8418**) SDA | Share with I2C bus | ❌ |
| Keyboard(**TCA8418**) SCL | Share with I2C bus | ❌ |
| Keyboard(**TCA8418**) Interrupt | 6 | ❌ |
| Keyboard Backlight | 46 | ❌ |
| Rotary Encoder A | 40 | ❌ |
| Rotary Encoder B | 41 | ❌ |
| Rotary Encoder Center | 7 | ❌ |
| RTC(**PCF85063A**) SDA | Share with I2C bus | ❌ |
| RTC(**PCF85063A**) SCL | Share with I2C bus | ❌ |
| RTC(**PCF85063A**) Interrupt | 1 | ❌ |
| NFC(**ST25R3916**) CS | 39 | ❌ |
| NFC(**ST25R3916**) Interrupt | 5 | ❌ |
| NFC(**ST25R3916**) MOSI | Share with SPI bus | ❌ |
| NFC(**ST25R3916**) MISO | Share with SPI bus | ❌ |
| NFC(**ST25R3916**) SCK | Share with SPI bus | ❌ |
| Sensor(**BHI260**) Interrupt | 8 | ❌ |
| Sensor(**BHI260**) SDA | Share with I2C bus | ❌ |
| Sensor(**BHI260**) SCL | Share with I2C bus | ❌ |
| Audio Codec(**ES8311**) WS | 18 | ❌ |
| Audio Codec(**ES8311**) SCK | 11 | ❌ |
| Audio Codec(**ES8311**) MCLK | 10 | ❌ |
| Audio Codec(**ES8311**) data out | 45 | ❌ |
| Audio Codec(**ES8311**) data in | 17 | ❌ |
| Audio Codec(**ES8311**) SDA | Share with I2C bus | ❌ |
| Audio Codec(**ES8311**) SCL | Share with I2C bus | ❌ |
| GNSS(**MIA-M10Q**) TX | 12 | ❌ |
| GNSS(**MIA-M10Q**) RX | 4 | ❌ |
| GNSS(**MIA-M10Q**) PPS | 13 | ❌ |
| LoRa(**SX1262 or SX1280**) SCK | Share with SPI bus | ❌ |
| LoRa(**SX1262 or SX1280**) MISO | Share with SPI bus | ❌ |
| LoRa(**SX1262 or SX1280**) MOSI | Share with SPI bus | ❌ |
| LoRa(**SX1262 or SX1280**) RESET | 47 | ❌ |
| LoRa(**SX1262 or SX1280**) BUSY | 48 | ❌ |
| LoRa(**SX1262 or SX1280**) CS | 36 | ❌ |
| LoRa(**SX1262 or SX1280**) Interrupt | 14 | ❌ |
| Display CS | 38 | ❌ |
| Display MOSI | Share with SPI bus | ❌ |
| Display MISO | Share with SPI bus | ❌ |
| Display SCK | Share with SPI bus | ❌ |
| Display DC | 37 | ❌ |
| Display RESET | Not Connected | ❌ |
| Display Backlight(16 Level) | 42 | ❌ |
| Gauge(**BQ27220**) SDA | Share with I2C bus | ❌ |
| Gauge(**BQ27220**) SCL | Share with I2C bus | ❌ |
| Charger(**BQ25896**) SDA | Share with I2C bus | ❌ |
| Charger(**BQ25896**) SCL | Share with I2C bus | ❌ |
| Haptic Driver(**DRV2605**) SDA | Share with I2C bus | ❌ |
| Haptic Driver(**DRV2605**) SCL | Share with I2C bus | ❌ |
| Expand(**XL9555**) SDA | Share with I2C bus | ❌ |
| Expand(**XL9555**) SCL | Share with I2C bus | ❌ |
| Expand(**XL9555**) GPIO0 | Haptic Driver Enable | ❌ |
| Expand(**XL9555**) GPIO1 | Audio Power Amplifier Enable | ❌ |
| Expand(**XL9555**) GPIO2 | Keyboard RESET | ❌ |
| Expand(**XL9555**) GPIO3 | LoRa Power supply Enable | ❌ |
| Expand(**XL9555**) GPIO4 | GNSS Power supply Enable | ❌ |
| Expand(**XL9555**) GPIO5 | NFC Power supply Enable | ❌ |
| Expand(**XL9555**) GPIO6 | ~~Display RESET~~ (No connect) | ❌ |
| Expand(**XL9555**) GPIO7 | GNSS RESET | ❌ |
| Expand(**XL9555**) GPIO10 | Keyboard Power supply Enable | ❌ |
| Expand(**XL9555**) GPIO11 | External 12-Pin socket | ✅️ |
| Expand(**XL9555**) GPIO12 | SD Insert Detect | ❌ |
| Expand(**XL9555**) GPIO14 | SD Power supply Enable | ❌ |
<!-- | Expand(**XL9555**) GPIO13 | SD PullUp Enable | ❌ | -->
The pin map below is taken from
[pins_arduino.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h),
which is the active variant source for this repo.
### 🧑🏼‍🔧 I2C Devices Address
### Shared I2C Bus
| Devices | 7-Bit Address | Share Bus |
| ------------------------------ | ------------- | --------- |
| [Codec ES8311][10] | 0x18 | ✅️ |
| [Expands IO XL9555][11] | 0x20 | ✅️ |
| [Smart sensor BHI260AP][5] | 0x28 | ✅️ |
| [Real-Time Clock PCF85063A][6] | 0x51 | ✅️ |
| [PowerManage BQ25896][7] | 0x6B | ✅️ |
| [Gauge BQ27220][8] | 0x55 | ✅️ |
| [Keyboard TCA8418][12] | 0x34 | ✅️ |
| [Haptic driver DRV2605][9] | 0x5A | ✅️ |
- SDA: `3`
- SCL: `2`
### ⚡ PowerManage Channel
Devices sharing this bus include:
| Channel | Peripherals |
| ------------------------ | ------------------ |
| Expand(**XL9555**) GPIO0 | **DRV2605 Enable** |
| Expand(**XL9555**) GPIO1 | **Speaker** |
| Expand(**XL9555**) GPIO3 | **LoRa** |
| Expand(**XL9555**) GPIO4 | **GNSS** |
| Expand(**XL9555**) GPIO5 | **NFC** |
| Expand(**XL9555**) GPIO8 | **Keyboard** |
| Expand(**XL9555**) GPIO14 | **SD Card** |
- `BHI260AP`
- `PCF85063`
- `BQ25896`
- `BQ27220`
- `DRV2605`
- `ES8311`
- `XL9555`
- `TCA8418`
### ⚡ Electrical parameters
### Shared SPI Bus
| Features | Details |
| -------------------------- | -------------------------- |
| 🔗USB-C Input Voltage | 3.9V-6V |
| 🔗USB-C Output Voltage | 4.55-5.55V |
| ⚡USB-C Output Current | 0.5-1A |
| ⚡Charge Current | 0-3008mA(\(Programmable\)) |
| 🔋Battery Voltage | 3.7V |
| 🔋Battery capacity | 1500mA (\(5.55Wh\)) |
| 🔋Charge Temperature Range | 0~60° |
- MOSI: `34`
- MISO: `33`
- SCK: `35`
> \[!IMPORTANT]
> ⚠️ Recommended to use a charging current lower than 750mA.
> The charging current should not be greater than half of the battery capacity
Bus users:
### ⚡ Power consumption reference
- LoRa radio
- SD card
- NFC
- display
| Mode | Wake-Up Mode | Current |
| ---------- | ------------ | ------- |
| DeepSleep | BootButton | 530uA |
| DeepSleep | Timer | 530uA |
| LightSleep | BootButton | ~2.26mA |
| Power OFF | PowerButton | 26uA |
### External UART / Expansion Header
### Resource
- TX: `43`
- RX: `44`
- custom external pin: `9`
* [Radio-SX1262(Sub 1G LoRa and FSK )](https://www.semtech.com/products/wireless-rf/lora-connect/sx1262)
* [Radio-SX1280(2.4G LoRa,FLRC,(G)FSK)](https://www.semtech.cn/products/wireless-rf/lora-connect/sx1280)
* [Radio-CC1101(Sub 1G (G)MSK, 2(G)FSK, 4(G)FSK, ASK, OOK)](https://www.ti.com/product/CC1101)
* [Radio-LR1121(Sub 1G + 2.4G LoRa)](https://www.semtech.com/products/wireless-rf/lora-connect/lr1121)
* [Radio-SI4432(Sub 1G ISM)](https://www.silabs.com/wireless/proprietary/ezradiopro-sub-ghz-ics/device.si4432?tab=specs)
* [Schematic](../../schematic/T-Watch%20Ultra%20V1.0%20SCH%2025-07-24.pdf)
### Buttons And Input
- Power / wake button: `0`
- Boot / custom button: `9`
- Rotary A: `40`
- Rotary B: `41`
- Rotary center: `7`
- Keyboard interrupt: `6`
- Keyboard backlight: `46`
Notes:
- this board uses a rotary encoder instead of a 5-way joystick
- the keyboard is handled through `TCA8418`
- the boot / power buttons are not interchangeable in behavior
### GNSS
- TX: `12`
- RX: `4`
- PPS: `13`
### LoRa
- CS: `36`
- RESET: `47`
- BUSY: `48`
- IRQ / DIO: `14`
- SPI bus: shared on `34/33/35`
### Display
- Driver: `ST7796`
- CS: `38`
- DC: `37`
- RESET: `-1` (`not connected`)
- Backlight: `42`
- SPI bus: shared on `34/33/35`
### SD Card
- CS: `21`
- SPI bus: shared on `34/33/35`
### Audio
- I2S WS: `18`
- I2S SCK: `11`
- I2S MCLK: `10`
- I2S data out: `45`
- I2S data in: `17`
### Interrupt Pins
- RTC interrupt: `1`
- NFC interrupt: `5`
- Motion sensor interrupt: `8`
### NFC
- ST25R3916 CS: `39`
- ST25R3916 interrupt: `5`
## XL9555 Power / Control Lines
The Pager uses an `XL9555` I/O expander to gate multiple peripherals.
Current logical assignments from
[pins_arduino.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h):
- `EXPANDS_DRV_EN = 0`
- `EXPANDS_AMP_EN = 1`
- `EXPANDS_KB_RST = 2`
- `EXPANDS_LORA_EN = 3`
- `EXPANDS_GPS_EN = 4`
- `EXPANDS_NFC_EN = 5`
- `EXPANDS_GPS_RST = 7`
- `EXPANDS_KB_EN = 8`
- `EXPANDS_GPIO_EN = 9`
- `EXPANDS_SD_DET = 10`
- `EXPANDS_SD_PULLEN = 11`
- `EXPANDS_SD_EN = 12`
Operationally this means power sequencing for LoRa, GNSS, NFC, keyboard, SD, audio
and haptics is not just raw GPIO configuration on the ESP32-S3. The expander state
also matters.
## Feature Flags
The active variant declares these board capabilities:
- `USING_AUDIO_CODEC`
- `USING_XL9555_EXPANDS`
- `USING_PPM_MANAGE`
- `USING_BQ_GAUGE`
- `USING_INPUT_DEV_ROTARY`
- `USING_INPUT_DEV_KEYBOARD`
- `USING_ST25R3916`
- `USING_BHI260_SENSOR`
- `HAS_SD_CARD_SOCKET`
These flags are part of the board contract and are relied on by the ESP platform code.
## Runtime Bring-Up Notes
The board runtime in
[TLoRaPagerBoard.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/platform/esp/boards/src/board/TLoRaPagerBoard.cpp)
currently initializes or manages:
- battery gauge `BQ27220`
- PMU `BQ25896`
- GPIO expander `XL9555`
- motion sensor `BHI260AP`
- RTC `PCF85063`
- NFC `ST25R3916`
- keyboard `TCA8418`
- audio codec `ES8311`
- LoRa radio
- display and related power lines
When debugging missing peripherals on Pager, always check both:
1. the raw pin assignment
2. the relevant `XL9555` enable line or runtime init path
## Display Notes
There are two dimensions worth remembering:
- vendor-facing panel spec is often described as `480 x 222`
- this repository also builds with `SCREEN_WIDTH=480` and `SCREEN_HEIGHT=222`
The panel is driven through `ST7796`, and physical orientation / UI rotation should be
validated in runtime code rather than assumed from the raw panel numbers alone.
## Known Risks / Maintenance Notes
- `boards/lilygo-t-lora-pager.json` currently points at variant `lilygo_twatch_ultra`
while the actual pin definitions used for Pager live under
[variants/lilygo_tlora_pager](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager).
This is worth treating carefully whenever board configuration is refactored.
- Pager hardware is highly multiplexed. A peripheral can fail because of shared-bus
contention, expander power state, or init order, not just because a GPIO number is wrong.
- LoRa, SD, NFC and display all share the SPI bus, so bus ownership issues are realistic.
- Many auxiliary devices share the same I2C bus, so probe order and bus locking matter.
## Maintenance Guidance
When changing this board next time:
1. Update [pins_arduino.h](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/pins_arduino.h) first for GPIO truth.
2. Update [tlora_pager.ini](/C:/Users/VicLi/Documents/Projects/trail-mate/variants/lilygo_tlora_pager/envs/tlora_pager.ini) if the radio or build flags change.
3. Update [TLoRaPagerBoard.cpp](/C:/Users/VicLi/Documents/Projects/trail-mate/platform/esp/boards/src/board/TLoRaPagerBoard.cpp) for init order, power gating or runtime behavior.
4. Keep this document aligned with the checked-in implementation, not with stale vendor copy.
@@ -1,6 +1,7 @@
#pragma once
#include "app/app_facades.h"
#include "chat/domain/contact_types.h"
#include "chat/usecase/chat_service.h"
#include "platform/ui/device_runtime.h"
#include "platform/ui/gps_runtime.h"
@@ -100,8 +101,11 @@ class Runtime : public chat::ChatService::IncomingTextObserver
{
BootLog = 0,
Screensaver,
Sleep,
MainMenu,
ChatList,
NodeList,
NodeInfo,
Conversation,
MessageMenu,
MessageInfo,
@@ -129,8 +133,11 @@ class Runtime : public chat::ChatService::IncomingTextObserver
void renderBootLog();
void renderScreensaver();
void renderSleep();
void renderMainMenu();
void renderChatList();
void renderNodeList();
void renderNodeInfo();
void renderConversation();
void renderMessageMenu();
void renderMessageInfo();
@@ -146,11 +153,14 @@ class Runtime : public chat::ChatService::IncomingTextObserver
void openCompose(EditTarget target, const char* seed_text = nullptr);
void finishTextEdit(bool accept);
void rebuildConversationList();
void rebuildNodeList();
void buildNodeInfo();
void rebuildMessages();
void buildMessageInfo();
void sendComposeMessage();
void commitConfig();
void ensureBootExit();
void ensureSleepTimeout(InputAction action);
void adjustRadioSetting(int delta);
void adjustDeviceSetting(int delta);
void adjustComposeSelection(int delta);
@@ -167,6 +177,7 @@ class Runtime : public chat::ChatService::IncomingTextObserver
void activateComposeAction();
void saveEditedTextToConfig();
void formatTime(char* out_time, size_t out_len, char* out_date, size_t date_len) const;
void formatTimestamp(char* out, size_t out_len, uint32_t timestamp_s) const;
void formatProtocol(char* out, size_t out_len) const;
void formatNodeLabel(char* out, size_t out_len) const;
void formatComposeTarget(char* out, size_t out_len) const;
@@ -187,8 +198,10 @@ class Runtime : public chat::ChatService::IncomingTextObserver
bool initialized_ = false;
Page page_ = Page::BootLog;
Page page_before_compose_ = Page::MainMenu;
Page page_before_sleep_ = Page::Screensaver;
uint32_t boot_started_ms_ = 0;
uint32_t page_entered_ms_ = 0;
uint32_t last_interaction_ms_ = 0;
static constexpr size_t kBootLogLines = 8;
static constexpr size_t kBootLogWidth = 32;
char boot_log_[kBootLogLines][kBootLogWidth] = {};
@@ -201,15 +214,26 @@ class Runtime : public chat::ChatService::IncomingTextObserver
size_t device_index_ = 0;
size_t action_index_ = 0;
size_t chat_list_index_ = 0;
size_t node_list_index_ = 0;
size_t node_info_scroll_ = 0;
size_t message_index_ = 0;
size_t message_menu_index_ = 0;
size_t message_info_scroll_ = 0;
size_t gnss_page_index_ = 0;
static constexpr size_t kMaxConversationItems = 8;
chat::ConversationMeta conversations_[kMaxConversationItems]{};
size_t conversation_count_ = 0;
size_t conversation_total_ = 0;
static constexpr size_t kMaxNodeItems = 16;
chat::contacts::NodeInfo nodes_[kMaxNodeItems]{};
size_t node_count_ = 0;
static constexpr size_t kNodeInfoLines = 24;
static constexpr size_t kNodeInfoWidth = 40;
char node_info_lines_[kNodeInfoLines][kNodeInfoWidth] = {};
size_t node_info_count_ = 0;
static constexpr size_t kMaxMessageItems = 12;
chat::ChatMessage messages_[kMaxMessageItems]{};
size_t message_count_ = 0;
+648 -45
View File
@@ -11,6 +11,7 @@
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <cstdlib>
@@ -39,12 +40,12 @@ const char* inputActionName(InputAction action)
constexpr const char* kMainMenuItems[] = {
"CHATS",
"NODES",
"NEW MESSAGE",
"SETTINGS",
"IDENTITY",
"GPS",
"RADIO",
"DEVICE",
"GNSS",
"ACTIONS",
};
@@ -115,9 +116,13 @@ constexpr ComposeGroupDef kComposeAbcGroups[] = {
{"WXYZ", "WXYZ"},
{".,?", ".,?"},
};
constexpr char kSelectedItemMarker[] = "\xE2\x97\x8F";
constexpr uint32_t kBootMinMs = 1800;
constexpr uint32_t kSleepTimeoutMs = 30000;
constexpr uint32_t kComposeMultiTapWindowMs = 700;
constexpr size_t kMessageInfoPageSize = 6;
constexpr size_t kNodeInfoPageSize = 6;
constexpr size_t kGnssSummaryPageSize = 6;
constexpr size_t kGnssSatPageSize = 5;
constexpr int kTimezoneMin = -12 * 60;
constexpr int kTimezoneMax = 14 * 60;
constexpr int kTimezoneStep = 60;
@@ -248,6 +253,114 @@ bool hasPrefixIgnoreCase(const char* text, const char* prefix)
return true;
}
bool equalsIgnoreCase(const char* a, const char* b)
{
if (!a || !b)
{
return false;
}
while (*a != '\0' && *b != '\0')
{
if (upperAscii(*a) != upperAscii(*b))
{
return false;
}
++a;
++b;
}
return *a == '\0' && *b == '\0';
}
double degToRad(double deg)
{
return deg * 3.14159265358979323846 / 180.0;
}
double radToDeg(double rad)
{
return rad * 180.0 / 3.14159265358979323846;
}
double normalizeBearingDeg(double deg)
{
while (deg < 0.0)
{
deg += 360.0;
}
while (deg >= 360.0)
{
deg -= 360.0;
}
return deg;
}
double haversineMeters(double lat1, double lon1, double lat2, double lon2)
{
constexpr double kEarthRadiusM = 6371000.0;
const double dlat = degToRad(lat2 - lat1);
const double dlon = degToRad(lon2 - lon1);
const double a = std::sin(dlat / 2.0) * std::sin(dlat / 2.0) +
std::cos(degToRad(lat1)) * std::cos(degToRad(lat2)) *
std::sin(dlon / 2.0) * std::sin(dlon / 2.0);
const double c = 2.0 * std::atan2(std::sqrt(a), std::sqrt(std::max(0.0, 1.0 - a)));
return kEarthRadiusM * c;
}
double bearingDegrees(double lat1, double lon1, double lat2, double lon2)
{
const double lat1r = degToRad(lat1);
const double lat2r = degToRad(lat2);
const double dlonr = degToRad(lon2 - lon1);
const double y = std::sin(dlonr) * std::cos(lat2r);
const double x = std::cos(lat1r) * std::sin(lat2r) -
std::sin(lat1r) * std::cos(lat2r) * std::cos(dlonr);
return normalizeBearingDeg(radToDeg(std::atan2(y, x)));
}
const char* bearingCardinal(double bearing_deg)
{
static constexpr const char* kDirs[] = {
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"};
const int index = static_cast<int>((normalizeBearingDeg(bearing_deg) + 11.25) / 22.5) % 16;
return kDirs[index];
}
const char* gnssFixLabel(::gps::GnssFix fix)
{
switch (fix)
{
case ::gps::GnssFix::FIX2D: return "2D";
case ::gps::GnssFix::FIX3D: return "3D";
case ::gps::GnssFix::NOFIX:
default: return "NO";
}
}
const char* gnssSystemLabel(::gps::GnssSystem sys)
{
switch (sys)
{
case ::gps::GnssSystem::GPS: return "GPS";
case ::gps::GnssSystem::GLN: return "GLN";
case ::gps::GnssSystem::GAL: return "GAL";
case ::gps::GnssSystem::BD: return "BDS";
case ::gps::GnssSystem::UNKNOWN:
default: return "UNK";
}
}
template <size_t N, typename... Args>
void pushFormattedLine(char (&lines)[N][40], size_t& line_count, const char* fmt, Args... args)
{
if (!fmt || line_count >= N)
{
return;
}
std::snprintf(lines[line_count], sizeof(lines[line_count]), fmt, args...);
++line_count;
}
const char* composeAbcGroupLetters(size_t index)
{
return index < arrayCount(kComposeAbcGroups) ? kComposeAbcGroups[index].input : "";
@@ -365,6 +478,7 @@ bool Runtime::begin()
initialized_ = display_.begin();
boot_started_ms_ = nowMs();
page_entered_ms_ = boot_started_ms_;
last_interaction_ms_ = boot_started_ms_;
return initialized_;
}
@@ -397,6 +511,7 @@ void Runtime::tick(InputAction action)
}
ensureBootExit();
ensureSleepTimeout(action);
handleInput(action);
render();
}
@@ -475,6 +590,21 @@ void Runtime::handleInput(InputAction action)
return;
}
if (page_ == Page::Sleep)
{
if (action != InputAction::None)
{
last_interaction_ms_ = nowMs();
enterPage(page_before_sleep_);
}
return;
}
if (action != InputAction::None)
{
last_interaction_ms_ = nowMs();
}
switch (page_)
{
case Page::MainMenu:
@@ -495,15 +625,15 @@ void Runtime::handleInput(InputAction action)
switch (main_menu_index_)
{
case 0: enterPage(Page::ChatList); break;
case 1:
case 1: enterPage(Page::NodeList); break;
case 2:
active_conversation_ = chat::ConversationId(chat::ChannelId::PRIMARY, 0, app()->getConfig().mesh_protocol);
openCompose(EditTarget::Message);
break;
case 2: enterPage(Page::SettingsMenu); break;
case 3: enterPage(Page::IdentitySettings); break;
case 4: enterPage(Page::RadioSettings); break;
case 5: enterPage(Page::DeviceSettings); break;
case 6: enterPage(Page::GnssPage); break;
case 3: enterPage(Page::SettingsMenu); break;
case 4: enterPage(Page::GnssPage); break;
case 5: enterPage(Page::RadioSettings); break;
case 6: enterPage(Page::DeviceSettings); break;
case 7: enterPage(Page::ActionPage); break;
default: break;
}
@@ -531,6 +661,43 @@ void Runtime::handleInput(InputAction action)
}
break;
case Page::NodeList:
if (action == InputAction::Up && node_list_index_ > 0)
{
--node_list_index_;
}
else if (action == InputAction::Down && node_list_index_ + 1 < node_count_)
{
++node_list_index_;
}
else if (action == InputAction::Left || action == InputAction::Back)
{
enterPage(Page::MainMenu);
}
else if (action == InputAction::Right || action == InputAction::Select || action == InputAction::Primary)
{
enterPage(Page::NodeInfo);
}
break;
case Page::NodeInfo:
if (action == InputAction::Up && node_info_scroll_ > 0)
{
node_info_scroll_ = (node_info_scroll_ >= kNodeInfoPageSize)
? (node_info_scroll_ - kNodeInfoPageSize)
: 0U;
}
else if (action == InputAction::Down && (node_info_scroll_ + kNodeInfoPageSize) < node_info_count_)
{
node_info_scroll_ += kNodeInfoPageSize;
}
else if (action == InputAction::Left || action == InputAction::Back ||
action == InputAction::Right || action == InputAction::Select || action == InputAction::Primary)
{
enterPage(Page::NodeList);
}
break;
case Page::Conversation:
if (action == InputAction::Up && message_index_ > 0)
{
@@ -579,11 +746,13 @@ void Runtime::handleInput(InputAction action)
case Page::MessageInfo:
if (action == InputAction::Up && message_info_scroll_ > 0)
{
--message_info_scroll_;
message_info_scroll_ = (message_info_scroll_ >= kMessageInfoPageSize)
? (message_info_scroll_ - kMessageInfoPageSize)
: 0U;
}
else if (action == InputAction::Down && message_info_scroll_ + 1 < message_info_count_)
else if (action == InputAction::Down && (message_info_scroll_ + kMessageInfoPageSize) < message_info_count_)
{
++message_info_scroll_;
message_info_scroll_ += kMessageInfoPageSize;
}
else if (action == InputAction::Left || action == InputAction::Back ||
action == InputAction::Right || action == InputAction::Select || action == InputAction::Primary)
@@ -894,7 +1063,15 @@ void Runtime::handleInput(InputAction action)
break;
case Page::GnssPage:
if (action == InputAction::Left || action == InputAction::Back || action == InputAction::Select)
if (action == InputAction::Up && gnss_page_index_ > 0)
{
--gnss_page_index_;
}
else if (action == InputAction::Down)
{
++gnss_page_index_;
}
else if (action == InputAction::Left || action == InputAction::Back || action == InputAction::Select)
{
enterPage(Page::MainMenu);
}
@@ -954,8 +1131,11 @@ void Runtime::render()
{
case Page::BootLog: renderBootLog(); break;
case Page::Screensaver: renderScreensaver(); break;
case Page::Sleep: renderSleep(); break;
case Page::MainMenu: renderMainMenu(); break;
case Page::ChatList: renderChatList(); break;
case Page::NodeList: renderNodeList(); break;
case Page::NodeInfo: renderNodeInfo(); break;
case Page::Conversation: renderConversation(); break;
case Page::MessageMenu: renderMessageMenu(); break;
case Page::MessageInfo: renderMessageInfo(); break;
@@ -1015,6 +1195,10 @@ void Runtime::renderScreensaver()
text_renderer_.drawText(display_, node_x, 50, node_buf);
}
void Runtime::renderSleep()
{
}
void Runtime::renderMainMenu()
{
drawMenuList("MENU", kMainMenuItems, arrayCount(kMainMenuItems), main_menu_index_);
@@ -1031,7 +1215,6 @@ void Runtime::renderChatList()
}
const int line_h = text_renderer_.lineHeight();
const int marker_w = text_renderer_.measureTextWidth(kSelectedItemMarker) + 2;
for (size_t i = 0; i < conversation_count_ && i < 6; ++i)
{
const bool selected = (i == chat_list_index_);
@@ -1041,11 +1224,86 @@ void Runtime::renderChatList()
conv.unread > 0 ? "*" : "",
conv.name.c_str());
const int y = 10 + static_cast<int>(i * line_h);
if (selected)
drawTextClipped(0, y, display_.width(), line, selected);
}
}
void Runtime::renderNodeList()
{
rebuildNodeList();
drawTitleBar("NODES", nullptr);
if (node_count_ == 0)
{
text_renderer_.drawText(display_, 0, 18, "NO NODES");
return;
}
const int line_h = text_renderer_.lineHeight();
constexpr size_t kNodeAliasMax = 8;
const size_t selected = std::min(node_list_index_, node_count_ - 1U);
const size_t visible = std::min(node_count_, static_cast<size_t>(6));
size_t start = 0;
if (node_count_ > visible)
{
start = (selected + 1 > visible) ? (selected + 1 - visible) : 0;
if (start + visible > node_count_)
{
text_renderer_.drawText(display_, 0, y, kSelectedItemMarker);
start = node_count_ - visible;
}
drawTextClipped(marker_w, y, display_.width() - marker_w, line, false);
}
for (size_t i = 0; i < visible; ++i)
{
const size_t node_index = start + i;
const auto& node = nodes_[node_index];
char node_id[8] = {};
std::snprintf(node_id, sizeof(node_id), "%04lX",
static_cast<unsigned long>(node.node_id & 0xFFFFUL));
char alias[kNodeAliasMax + 1] = {};
if (!node.display_name.empty())
{
copyText(alias, node.display_name.c_str());
alias[kNodeAliasMax] = '\0';
}
char line[40] = {};
if (alias[0] == '\0' || equalsIgnoreCase(alias, node_id))
{
std::snprintf(line, sizeof(line), "%s", node_id);
}
else
{
std::snprintf(line, sizeof(line), "%s %s", node_id, alias);
}
drawTextClipped(0, 10 + static_cast<int>(i * line_h), display_.width(), line, node_index == selected);
}
}
void Runtime::renderNodeInfo()
{
buildNodeInfo();
char pos[24] = {};
if (node_info_count_ > 0)
{
const size_t total_pages = (node_info_count_ + kNodeInfoPageSize - 1U) / kNodeInfoPageSize;
const size_t current_page = (node_info_scroll_ / kNodeInfoPageSize) + 1U;
std::snprintf(pos, sizeof(pos), "%u/%u",
static_cast<unsigned>(current_page),
static_cast<unsigned>(total_pages));
}
drawTitleBar("NODE", pos[0] != '\0' ? pos : nullptr);
if (node_info_count_ == 0)
{
text_renderer_.drawText(display_, 0, 18, "NO INFO");
return;
}
const int line_h = text_renderer_.lineHeight();
const size_t start = std::min(node_info_scroll_, node_info_count_);
const size_t visible = std::min(node_info_count_ - start, kNodeInfoPageSize);
for (size_t i = 0; i < visible && (start + i) < node_info_count_; ++i)
{
drawTextClipped(0, 10 + static_cast<int>(i * line_h), display_.width(), node_info_lines_[start + i], false);
}
}
@@ -1070,9 +1328,8 @@ void Runtime::renderConversation()
}
const int line_h = text_renderer_.lineHeight();
const int marker_w = text_renderer_.measureTextWidth(kSelectedItemMarker) + 2;
const size_t selected_index = std::min(message_index_, message_count_ - 1U);
const size_t visible = std::min(message_count_, static_cast<size_t>(5));
const size_t visible = std::min(message_count_, static_cast<size_t>(6));
size_t start = 0;
if (message_count_ > visible)
{
@@ -1100,11 +1357,7 @@ void Runtime::renderConversation()
std::snprintf(line, sizeof(line), "%s>%s", sender, msg.text.c_str());
const int y = 10 + static_cast<int>(i * line_h);
if (selected)
{
text_renderer_.drawText(display_, 0, y, kSelectedItemMarker);
}
drawTextClipped(marker_w, y, display_.width() - marker_w, line, false);
drawTextClipped(0, y, display_.width(), line, selected);
}
}
@@ -1116,12 +1369,14 @@ void Runtime::renderMessageMenu()
void Runtime::renderMessageInfo()
{
buildMessageInfo();
char pos[12] = {};
char pos[24] = {};
if (message_info_count_ > 0)
{
const size_t total_pages = (message_info_count_ + kMessageInfoPageSize - 1U) / kMessageInfoPageSize;
const size_t current_page = (message_info_scroll_ / kMessageInfoPageSize) + 1U;
std::snprintf(pos, sizeof(pos), "%u/%u",
static_cast<unsigned>(std::min(message_info_scroll_ + 1, message_info_count_)),
static_cast<unsigned>(message_info_count_));
static_cast<unsigned>(current_page),
static_cast<unsigned>(total_pages));
}
drawTitleBar("INFO", pos[0] != '\0' ? pos : nullptr);
if (message_info_count_ == 0)
@@ -1131,9 +1386,8 @@ void Runtime::renderMessageInfo()
}
const int line_h = text_renderer_.lineHeight();
const size_t visible = std::min(message_info_count_, static_cast<size_t>(6));
const size_t start = std::min(message_info_scroll_,
message_info_count_ > visible ? message_info_count_ - visible : 0U);
const size_t start = std::min(message_info_scroll_, message_info_count_);
const size_t visible = std::min(message_info_count_ - start, kMessageInfoPageSize);
for (size_t i = 0; i < visible && (start + i) < message_info_count_; ++i)
{
drawTextClipped(0, 10 + static_cast<int>(i * line_h), display_.width(), message_info_lines_[start + i], false);
@@ -1457,21 +1711,143 @@ void Runtime::renderDeviceSettings()
void Runtime::renderGnssPage()
{
drawTitleBar("GNSS", nullptr);
const auto state = host_.gps_data_fn ? host_.gps_data_fn() : platform::ui::gps::GpsState{};
char line[40] = {};
std::snprintf(line, sizeof(line), "ENABLED: %s", (host_.gps_enabled_fn && host_.gps_enabled_fn()) ? "YES" : "NO");
text_renderer_.drawText(display_, 0, 12, line);
std::snprintf(line, sizeof(line), "POWERED: %s", (host_.gps_powered_fn && host_.gps_powered_fn()) ? "YES" : "NO");
text_renderer_.drawText(display_, 0, 22, line);
std::snprintf(line, sizeof(line), "FIX: %s", state.valid ? "YES" : "NO");
text_renderer_.drawText(display_, 0, 32, line);
platform::ui::gps::GnssStatus status{};
std::size_t sat_count = 0;
platform::ui::gps::GnssSatInfo sats[::gps::kMaxGnssSats] = {};
(void)platform::ui::gps::get_gnss_snapshot(sats, ::gps::kMaxGnssSats, &sat_count, &status);
char summary_lines[14][40] = {};
size_t summary_count = 0;
pushFormattedLine(summary_lines, summary_count, "USE:%u/%u",
static_cast<unsigned>(status.sats_in_use),
static_cast<unsigned>(status.sats_in_view > 0 ? status.sats_in_view : sat_count));
pushFormattedLine(summary_lines, summary_count, "FIX:%s", gnssFixLabel(status.fix));
pushFormattedLine(summary_lines, summary_count, "HDOP:%.1f", static_cast<double>(status.hdop));
if ((host_.gps_enabled_fn && host_.gps_enabled_fn()) && (host_.gps_powered_fn && host_.gps_powered_fn()))
{
if (sat_count == 0)
{
pushFormattedLine(summary_lines, summary_count, "STATE:TIME ONLY");
}
else if (!state.valid)
{
pushFormattedLine(summary_lines, summary_count, "STATE:SEARCH FIX");
}
else
{
pushFormattedLine(summary_lines, summary_count, "STATE:LOCKED");
}
}
else
{
pushFormattedLine(summary_lines, summary_count, "STATE:GPS OFF");
}
pushFormattedLine(summary_lines, summary_count, "EN:%s", (host_.gps_enabled_fn && host_.gps_enabled_fn()) ? "YES" : "NO");
pushFormattedLine(summary_lines, summary_count, "PWR:%s", (host_.gps_powered_fn && host_.gps_powered_fn()) ? "YES" : "NO");
pushFormattedLine(summary_lines, summary_count, "AGE:%lums", static_cast<unsigned long>(state.age));
if (state.valid)
{
std::snprintf(line, sizeof(line), "LAT %.4f", state.lat);
text_renderer_.drawText(display_, 0, 42, line);
std::snprintf(line, sizeof(line), "LNG %.4f", state.lng);
text_renderer_.drawText(display_, 0, 52, line);
pushFormattedLine(summary_lines, summary_count, "LAT:%.5f", state.lat);
pushFormattedLine(summary_lines, summary_count, "LON:%.5f", state.lng);
}
else
{
pushFormattedLine(summary_lines, summary_count, "LAT:-");
pushFormattedLine(summary_lines, summary_count, "LON:-");
}
if (state.has_alt)
{
pushFormattedLine(summary_lines, summary_count, "ALT:%.0fm", state.alt_m);
}
else
{
pushFormattedLine(summary_lines, summary_count, "ALT:-");
}
if (state.has_speed)
{
pushFormattedLine(summary_lines, summary_count, "SPD:%.1fkmh", state.speed_mps * 3.6);
}
else
{
pushFormattedLine(summary_lines, summary_count, "SPD:-");
}
if (state.has_course)
{
pushFormattedLine(summary_lines, summary_count, "CRS:%.0f %s", state.course_deg, bearingCardinal(state.course_deg));
}
else
{
pushFormattedLine(summary_lines, summary_count, "CRS:-");
}
const uint32_t last_motion_ms = platform::ui::gps::last_motion_ms();
if (last_motion_ms > 0)
{
const uint32_t age_s = nowMs() >= last_motion_ms ? (nowMs() - last_motion_ms) / 1000U : 0U;
pushFormattedLine(summary_lines, summary_count, "MOVE:%lus", static_cast<unsigned long>(age_s));
}
else
{
pushFormattedLine(summary_lines, summary_count, "MOVE:-");
}
const size_t summary_pages = std::max<size_t>(1U, (summary_count + kGnssSummaryPageSize - 1U) / kGnssSummaryPageSize);
const size_t sat_pages = std::max<size_t>(1U, (sat_count + kGnssSatPageSize - 1U) / kGnssSatPageSize);
const size_t total_pages = summary_pages + sat_pages;
if (gnss_page_index_ >= total_pages)
{
gnss_page_index_ = total_pages - 1U;
}
char pos[16] = {};
std::snprintf(pos, sizeof(pos), "%u/%u",
static_cast<unsigned>(gnss_page_index_ + 1U),
static_cast<unsigned>(total_pages));
drawTitleBar("GPS", pos);
const int line_h = text_renderer_.lineHeight();
if (gnss_page_index_ < summary_pages)
{
const size_t start = gnss_page_index_ * kGnssSummaryPageSize;
const size_t visible = std::min(kGnssSummaryPageSize, summary_count - std::min(start, summary_count));
for (size_t i = 0; i < visible; ++i)
{
text_renderer_.drawText(display_, 0, 10 + static_cast<int>(i * line_h), summary_lines[start + i]);
}
return;
}
display_.fillRect(0, 10, display_.width(), line_h, true);
text_renderer_.drawText(display_, 0, 10, "SAT ID USE SNR ELV AZI", true);
const size_t sat_page = gnss_page_index_ - summary_pages;
const size_t sat_start = sat_page * kGnssSatPageSize;
const size_t sat_visible = std::min(kGnssSatPageSize, sat_count - std::min(sat_start, sat_count));
if (sat_visible == 0)
{
text_renderer_.drawText(display_, 0, 22, "NO SAT DATA");
return;
}
for (size_t i = 0; i < sat_visible; ++i)
{
const auto& sat = sats[sat_start + i];
char line[40] = {};
std::snprintf(line,
sizeof(line),
"%-3s %02u %c %03d %02u %03u",
gnssSystemLabel(sat.sys),
static_cast<unsigned>(sat.id),
sat.used ? 'Y' : '-',
static_cast<int>(sat.snr >= 0 ? sat.snr : 0),
static_cast<unsigned>(sat.elevation),
static_cast<unsigned>(sat.azimuth));
text_renderer_.drawText(display_, 0, 20 + static_cast<int>(i * line_h), line);
}
}
@@ -1489,6 +1865,16 @@ void Runtime::enterPage(Page page)
rebuildConversationList();
chat_list_index_ = std::min(chat_list_index_, conversation_count_ == 0 ? 0U : conversation_count_ - 1U);
}
else if (page == Page::NodeList)
{
rebuildNodeList();
node_list_index_ = std::min(node_list_index_, node_count_ == 0 ? 0U : node_count_ - 1U);
}
else if (page == Page::NodeInfo)
{
node_info_scroll_ = 0;
buildNodeInfo();
}
else if (page == Page::Conversation)
{
if (app())
@@ -1508,6 +1894,10 @@ void Runtime::enterPage(Page page)
message_info_scroll_ = 0;
buildMessageInfo();
}
else if (page == Page::GnssPage)
{
gnss_page_index_ = 0;
}
}
void Runtime::openCompose(EditTarget target, const char* seed_text)
@@ -1571,6 +1961,157 @@ void Runtime::rebuildConversationList()
}
}
void Runtime::rebuildNodeList()
{
node_count_ = 0;
if (!app())
{
return;
}
auto contacts = app()->getContactService().getContacts();
auto nearby = app()->getContactService().getNearby();
contacts.insert(contacts.end(), nearby.begin(), nearby.end());
std::sort(contacts.begin(), contacts.end(),
[](const chat::contacts::NodeInfo& a, const chat::contacts::NodeInfo& b)
{
if (a.last_seen != b.last_seen)
{
return a.last_seen > b.last_seen;
}
return a.node_id < b.node_id;
});
node_count_ = std::min(contacts.size(), static_cast<size_t>(kMaxNodeItems));
for (size_t i = 0; i < node_count_; ++i)
{
nodes_[i] = contacts[i];
}
if (node_count_ == 0)
{
node_list_index_ = 0;
}
else if (node_list_index_ >= node_count_)
{
node_list_index_ = node_count_ - 1U;
}
}
void Runtime::buildNodeInfo()
{
node_info_count_ = 0;
if (node_count_ == 0)
{
return;
}
const size_t index = std::min(node_list_index_, node_count_ - 1U);
const auto& node = nodes_[index];
auto push_line = [this](const char* text)
{
if (!text || text[0] == '\0' || node_info_count_ >= kNodeInfoLines)
{
return;
}
copyText(node_info_lines_[node_info_count_], text);
++node_info_count_;
};
auto push_kv = [this](const char* key, const char* value)
{
if (!key || !value || node_info_count_ >= kNodeInfoLines)
{
return;
}
appendInfoLine(node_info_lines_[node_info_count_], key, value);
++node_info_count_;
};
auto push_section = [this](const char* title)
{
if (!title || node_info_count_ >= kNodeInfoLines)
{
return;
}
setInfoSection(node_info_lines_[node_info_count_], title);
++node_info_count_;
};
char value[40] = {};
push_section("NODE");
std::snprintf(value, sizeof(value), "%08lX", static_cast<unsigned long>(node.node_id));
push_kv("ID", value);
push_kv("NM", node.display_name.empty() ? "-" : node.display_name.c_str());
push_kv("SH", node.short_name[0] != '\0' ? node.short_name : "-");
push_kv("LN", node.long_name[0] != '\0' ? node.long_name : "-");
push_section("LINK");
push_kv("P", node.protocol == chat::contacts::NodeProtocolType::MeshCore ? "MC" :
node.protocol == chat::contacts::NodeProtocolType::Meshtastic ? "MT" : "?");
std::snprintf(value, sizeof(value), "%u", static_cast<unsigned>(node.hops_away));
push_kv("HP", node.hops_away == 0xFF ? "-" : value);
std::snprintf(value, sizeof(value), "%u", static_cast<unsigned>(node.channel));
push_kv("CH", node.channel == 0xFF ? "-" : value);
std::snprintf(value, sizeof(value), "%.1f", static_cast<double>(node.snr));
push_kv("SN", value);
std::snprintf(value, sizeof(value), "%.1f", static_cast<double>(node.rssi));
push_kv("RS", value);
formatTimestamp(value, sizeof(value), node.last_seen);
push_kv("SEEN", value[0] != '\0' ? value : "-");
push_section("POS");
if (node.position.valid)
{
std::snprintf(value, sizeof(value), "%.5f", static_cast<double>(node.position.latitude_i) / 1e7);
push_kv("LAT", value);
std::snprintf(value, sizeof(value), "%.5f", static_cast<double>(node.position.longitude_i) / 1e7);
push_kv("LON", value);
if (node.position.has_altitude)
{
std::snprintf(value, sizeof(value), "%ldm", static_cast<long>(node.position.altitude));
push_kv("ALT", value);
}
formatTimestamp(value, sizeof(value), node.position.timestamp);
push_kv("TIME", value[0] != '\0' ? value : "-");
}
else
{
push_line("NO POSITION");
}
const auto gps = host_.gps_data_fn ? host_.gps_data_fn() : platform::ui::gps::GpsState{};
if (gps.valid && node.position.valid)
{
const double node_lat = static_cast<double>(node.position.latitude_i) / 1e7;
const double node_lon = static_cast<double>(node.position.longitude_i) / 1e7;
const double dist_m = haversineMeters(gps.lat, gps.lng, node_lat, node_lon);
const double brg_deg = bearingDegrees(gps.lat, gps.lng, node_lat, node_lon);
push_section("NAV");
if (dist_m < 1000.0)
{
std::snprintf(value, sizeof(value), "%.0fm", dist_m);
}
else
{
std::snprintf(value, sizeof(value), "%.2fkm", dist_m / 1000.0);
}
push_kv("DST", value);
std::snprintf(value, sizeof(value), "%s %.0f", bearingCardinal(brg_deg), brg_deg);
push_kv("DIR", value);
if (gps.has_course)
{
const double rel = normalizeBearingDeg(brg_deg - gps.course_deg);
std::snprintf(value, sizeof(value), "%.0f", rel);
push_kv("REL", value);
}
}
}
void Runtime::rebuildMessages()
{
message_count_ = 0;
@@ -1583,7 +2124,7 @@ void Runtime::rebuildMessages()
message_count_ = std::min(list.size(), static_cast<size_t>(kMaxMessageItems));
for (size_t i = 0; i < message_count_; ++i)
{
messages_[i] = list[i];
messages_[i] = list[message_count_ - 1U - i];
}
if (message_count_ == 0)
{
@@ -1808,6 +2349,28 @@ void Runtime::ensureBootExit()
}
}
void Runtime::ensureSleepTimeout(InputAction action)
{
if (page_ == Page::BootLog || page_ == Page::Sleep)
{
return;
}
const uint32_t now = nowMs();
if (action != InputAction::None)
{
return;
}
if ((now - last_interaction_ms_) < kSleepTimeoutMs)
{
return;
}
page_before_sleep_ = page_;
enterPage(Page::Sleep);
}
void Runtime::adjustRadioSetting(int delta)
{
if (!app())
@@ -2345,6 +2908,34 @@ void Runtime::formatTime(char* out_time, size_t out_len, char* out_date, size_t
}
}
void Runtime::formatTimestamp(char* out, size_t out_len, uint32_t timestamp_s) const
{
if (!out || out_len == 0)
{
return;
}
out[0] = '\0';
if (timestamp_s == 0)
{
return;
}
const int tz_offset_s = (host_.timezone_offset_min_fn ? host_.timezone_offset_min_fn() : 0) * 60;
const time_t adjusted = static_cast<time_t>(timestamp_s + tz_offset_s);
const std::tm* tm = std::gmtime(&adjusted);
if (!tm)
{
std::snprintf(out, out_len, "%lu", static_cast<unsigned long>(timestamp_s));
return;
}
std::snprintf(out, out_len, "%02d-%02d %02d:%02d",
tm->tm_mon + 1,
tm->tm_mday,
tm->tm_hour,
tm->tm_min);
}
void Runtime::formatProtocol(char* out, size_t out_len) const
{
if (!out || out_len == 0 || !app())
@@ -2398,9 +2989,21 @@ void Runtime::drawMenuList(const char* title, const char* const* items, size_t c
{
drawTitleBar(title, nullptr);
const int line_h = text_renderer_.lineHeight();
for (size_t i = 0; i < count && i < 5; ++i)
const size_t visible = std::min(count, static_cast<size_t>(6));
size_t start = 0;
if (count > visible)
{
drawTextClipped(0, 10 + static_cast<int>(i * line_h), display_.width(), items[i], i == selected);
start = (selected + 1 > visible) ? (selected + 1 - visible) : 0;
if (start + visible > count)
{
start = count - visible;
}
}
for (size_t i = 0; i < visible && (start + i) < count; ++i)
{
const size_t item_index = start + i;
drawTextClipped(0, 10 + static_cast<int>(i * line_h), display_.width(), items[item_index], item_index == selected);
}
}