diff --git a/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp b/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp index 039e77f2..e269af74 100644 --- a/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp +++ b/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp @@ -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(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(event->pressed ? 1 : 0), - input_action_name(action)); - } s_runtime->tick(action); } } diff --git a/boards/gat562_mesh_evb_pro/src/gat562_board.cpp b/boards/gat562_mesh_evb_pro/src/gat562_board.cpp index 6077dcde..927f7ed1 100644 --- a/boards/gat562_mesh_evb_pro/src/gat562_board.cpp +++ b/boards/gat562_mesh_evb_pro/src/gat562_board.cpp @@ -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(snapshot.button_primary ? 1 : 0), - static_cast(snapshot.button_secondary ? 1 : 0), - static_cast(snapshot.joystick_up ? 1 : 0), - static_cast(snapshot.joystick_down ? 1 : 0), - static_cast(snapshot.joystick_left ? 1 : 0), - static_cast(snapshot.joystick_right ? 1 : 0), - static_cast(snapshot.joystick_press ? 1 : 0), - static_cast(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(utc_s), + static_cast(prev_epoch_s), + static_cast(s_gps.parser.satellites.isValid() ? s_gps.parser.satellites.value() : 0U), + static_cast(s_gps.parser.location.isValid() ? 1U : 0U), + static_cast(s_gps.parser.location.isValid() ? s_gps.parser.location.age() : 0U), + static_cast(year), + static_cast(month), + static_cast(day), + static_cast(hour), + static_cast(minute), + static_cast(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(0); + const bool epoch_ok = utc >= static_cast(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(s_gps.enabled ? 1 : 0), + static_cast(s_gps.powered ? 1 : 0), + static_cast(s_gps.nmea_seen ? 1 : 0), + static_cast(nmea_age_ms), + static_cast(time_valid ? 1 : 0), + static_cast(date_valid ? 1 : 0), + static_cast(fix_valid ? 1 : 0), + static_cast(sat_count), + fix_valid ? s_gps.parser.location.lat() : 0.0, + fix_valid ? s_gps.parser.location.lng() : 0.0, + static_cast(s_gps.epoch_base_s), + static_cast(epoch_ok ? static_cast(utc) : 0U), + static_cast(year), + static_cast(month), + static_cast(day), + static_cast(hour), + static_cast(minute), + static_cast(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(s_gps.enabled ? 1 : 0), + static_cast(s_gps.collection_interval_ms), + static_cast(s_gps.power_strategy), + static_cast(s_gps.gnss_mode), + static_cast(s_gps.sat_mask), + static_cast(s_gps.nmea_output_hz), + static_cast(s_gps.nmea_sentence_mask), + static_cast(s_gps.motion_idle_timeout_ms), + static_cast(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(epoch_s), + static_cast(prev_epoch_s)); syncSystemClockFromEpoch(epoch_s); } diff --git a/docs/devices/lilygo-t-lora-pager.md b/docs/devices/lilygo-t-lora-pager.md index ea95d428..17716c79 100644 --- a/docs/devices/lilygo-t-lora-pager.md +++ b/docs/devices/lilygo-t-lora-pager.md @@ -1,20 +1,41 @@ -
- LilyGo logo -
+# LilyGo T-LoRa Pager -

🌟LilyGo T-LoRa-Pager🌟

+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 | ❌ | - +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. diff --git a/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h b/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h index 0f570723..3661b51c 100644 --- a/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h +++ b/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h @@ -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; diff --git a/modules/ui_mono_128x64/src/runtime.cpp b/modules/ui_mono_128x64/src/runtime.cpp index 970b59ed..833daa6a 100644 --- a/modules/ui_mono_128x64/src/runtime.cpp +++ b/modules/ui_mono_128x64/src/runtime.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -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((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 +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(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(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(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(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(current_page), + static_cast(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(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(5)); + const size_t visible = std::min(message_count_, static_cast(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(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(std::min(message_info_scroll_ + 1, message_info_count_)), - static_cast(message_info_count_)); + static_cast(current_page), + static_cast(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(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(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(status.sats_in_use), + static_cast(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(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(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(age_s)); + } + else + { + pushFormattedLine(summary_lines, summary_count, "MOVE:-"); + } + + const size_t summary_pages = std::max(1U, (summary_count + kGnssSummaryPageSize - 1U) / kGnssSummaryPageSize); + const size_t sat_pages = std::max(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(gnss_page_index_ + 1U), + static_cast(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(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(sat.id), + sat.used ? 'Y' : '-', + static_cast(sat.snr >= 0 ? sat.snr : 0), + static_cast(sat.elevation), + static_cast(sat.azimuth)); + text_renderer_.drawText(display_, 0, 20 + static_cast(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(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(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(node.hops_away)); + push_kv("HP", node.hops_away == 0xFF ? "-" : value); + std::snprintf(value, sizeof(value), "%u", static_cast(node.channel)); + push_kv("CH", node.channel == 0xFF ? "-" : value); + std::snprintf(value, sizeof(value), "%.1f", static_cast(node.snr)); + push_kv("SN", value); + std::snprintf(value, sizeof(value), "%.1f", static_cast(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(node.position.latitude_i) / 1e7); + push_kv("LAT", value); + std::snprintf(value, sizeof(value), "%.5f", static_cast(node.position.longitude_i) / 1e7); + push_kv("LON", value); + if (node.position.has_altitude) + { + std::snprintf(value, sizeof(value), "%ldm", static_cast(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(node.position.latitude_i) / 1e7; + const double node_lon = static_cast(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(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(timestamp_s + tz_offset_s); + const std::tm* tm = std::gmtime(&adjusted); + if (!tm) + { + std::snprintf(out, out_len, "%lu", static_cast(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(6)); + size_t start = 0; + if (count > visible) { - drawTextClipped(0, 10 + static_cast(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(i * line_h), display_.width(), items[item_index], item_index == selected); } }