mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-08-28 05:24:08 +00:00
Refine mono UI GPS pages and board diagnostics
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t);
|
||||
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
@@ -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 | -20~70°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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user