From 1255f0db51d9fd5f9a7c3659af0ed8acc95ae33f Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:40:34 -0700 Subject: [PATCH] Initial release: Ratdeck v1.0.0 Reticulum transport node + LXMF encrypted messenger for LilyGo T-Deck Plus. ESP32-S3, 16MB flash, SX1262 LoRa, LovyanGFX display, NimBLE BLE. --- .github/workflows/build.yml | 81 +++ .gitignore | 14 + README.md | 297 +++++++++++ docs/ARCHITECTURE.md | 151 ++++++ docs/BUILDING.md | 160 ++++++ docs/DEVELOPMENT.md | 348 +++++++++++++ docs/HOTKEYS.md | 69 +++ docs/PINMAP.md | 107 ++++ docs/QUICKSTART.md | 152 ++++++ docs/TROUBLESHOOTING.md | 257 +++++++++ lv_conf.h | 64 +++ partitions_16MB.csv | 7 + platformio.ini | 40 ++ src/audio/AudioNotify.cpp | 207 ++++++++ src/audio/AudioNotify.h | 29 ++ src/config/BoardConfig.h | 85 +++ src/config/Config.h | 62 +++ src/config/UserConfig.cpp | 176 +++++++ src/config/UserConfig.h | 78 +++ src/hal/Audio.cpp | 13 + src/hal/Audio.h | 15 + src/hal/Display.cpp | 36 ++ src/hal/Display.h | 69 +++ src/hal/GPS.cpp | 13 + src/hal/GPS.h | 19 + src/hal/Keyboard.cpp | 100 ++++ src/hal/Keyboard.h | 57 ++ src/hal/Power.cpp | 94 ++++ src/hal/Power.h | 39 ++ src/hal/TouchInput.cpp | 94 ++++ src/hal/TouchInput.h | 26 + src/hal/Trackball.cpp | 58 +++ src/hal/Trackball.h | 46 ++ src/input/HotkeyManager.cpp | 23 + src/input/HotkeyManager.h | 29 ++ src/input/InputManager.cpp | 85 +++ src/input/InputManager.h | 34 ++ src/lv_conf.h | 64 +++ src/main.cpp | 750 +++++++++++++++++++++++++++ src/power/PowerManager.cpp | 59 +++ src/power/PowerManager.h | 35 ++ src/radio/RadioConstants.h | 102 ++++ src/radio/SX1262.cpp | 677 ++++++++++++++++++++++++ src/radio/SX1262.h | 140 +++++ src/reticulum/AnnounceManager.cpp | 192 +++++++ src/reticulum/AnnounceManager.h | 50 ++ src/reticulum/LXMFManager.cpp | 127 +++++ src/reticulum/LXMFManager.h | 44 ++ src/reticulum/LXMFMessage.cpp | 142 +++++ src/reticulum/LXMFMessage.h | 30 ++ src/reticulum/ReticulumManager.cpp | 219 ++++++++ src/reticulum/ReticulumManager.h | 72 +++ src/storage/FlashStore.cpp | 128 +++++ src/storage/FlashStore.h | 27 + src/storage/MessageStore.cpp | 361 +++++++++++++ src/storage/MessageStore.h | 33 ++ src/storage/SDStore.cpp | 192 +++++++ src/storage/SDStore.h | 34 ++ src/transport/BLEInterface.cpp | 164 ++++++ src/transport/BLEInterface.h | 77 +++ src/transport/BLESideband.cpp | 148 ++++++ src/transport/BLESideband.h | 65 +++ src/transport/LoRaInterface.cpp | 119 +++++ src/transport/LoRaInterface.h | 24 + src/transport/TCPClientInterface.cpp | 125 +++++ src/transport/TCPClientInterface.h | 41 ++ src/transport/WiFiInterface.cpp | 177 +++++++ src/transport/WiFiInterface.h | 61 +++ src/ui/StatusBar.cpp | 105 ++++ src/ui/StatusBar.h | 41 ++ src/ui/TabBar.cpp | 64 +++ src/ui/TabBar.h | 36 ++ src/ui/Theme.cpp | 2 + src/ui/Theme.h | 40 ++ src/ui/UIManager.cpp | 103 ++++ src/ui/UIManager.h | 65 +++ src/ui/screens/BootScreen.cpp | 55 ++ src/ui/screens/BootScreen.h | 15 + src/ui/screens/HelpOverlay.cpp | 52 ++ src/ui/screens/HelpOverlay.h | 17 + src/ui/screens/HomeScreen.cpp | 73 +++ src/ui/screens/HomeScreen.h | 26 + src/ui/screens/MapScreen.cpp | 25 + src/ui/screens/MapScreen.h | 9 + src/ui/screens/MessageView.cpp | 170 ++++++ src/ui/screens/MessageView.h | 34 ++ src/ui/screens/MessagesScreen.cpp | 95 ++++ src/ui/screens/MessagesScreen.h | 28 + src/ui/screens/NodesScreen.cpp | 97 ++++ src/ui/screens/NodesScreen.h | 28 + src/ui/screens/SettingsScreen.cpp | 490 +++++++++++++++++ src/ui/screens/SettingsScreen.h | 90 ++++ 92 files changed, 9473 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BUILDING.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/HOTKEYS.md create mode 100644 docs/PINMAP.md create mode 100644 docs/QUICKSTART.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 lv_conf.h create mode 100644 partitions_16MB.csv create mode 100644 platformio.ini create mode 100644 src/audio/AudioNotify.cpp create mode 100644 src/audio/AudioNotify.h create mode 100644 src/config/BoardConfig.h create mode 100644 src/config/Config.h create mode 100644 src/config/UserConfig.cpp create mode 100644 src/config/UserConfig.h create mode 100644 src/hal/Audio.cpp create mode 100644 src/hal/Audio.h create mode 100644 src/hal/Display.cpp create mode 100644 src/hal/Display.h create mode 100644 src/hal/GPS.cpp create mode 100644 src/hal/GPS.h create mode 100644 src/hal/Keyboard.cpp create mode 100644 src/hal/Keyboard.h create mode 100644 src/hal/Power.cpp create mode 100644 src/hal/Power.h create mode 100644 src/hal/TouchInput.cpp create mode 100644 src/hal/TouchInput.h create mode 100644 src/hal/Trackball.cpp create mode 100644 src/hal/Trackball.h create mode 100644 src/input/HotkeyManager.cpp create mode 100644 src/input/HotkeyManager.h create mode 100644 src/input/InputManager.cpp create mode 100644 src/input/InputManager.h create mode 100644 src/lv_conf.h create mode 100644 src/main.cpp create mode 100644 src/power/PowerManager.cpp create mode 100644 src/power/PowerManager.h create mode 100644 src/radio/RadioConstants.h create mode 100644 src/radio/SX1262.cpp create mode 100644 src/radio/SX1262.h create mode 100644 src/reticulum/AnnounceManager.cpp create mode 100644 src/reticulum/AnnounceManager.h create mode 100644 src/reticulum/LXMFManager.cpp create mode 100644 src/reticulum/LXMFManager.h create mode 100644 src/reticulum/LXMFMessage.cpp create mode 100644 src/reticulum/LXMFMessage.h create mode 100644 src/reticulum/ReticulumManager.cpp create mode 100644 src/reticulum/ReticulumManager.h create mode 100644 src/storage/FlashStore.cpp create mode 100644 src/storage/FlashStore.h create mode 100644 src/storage/MessageStore.cpp create mode 100644 src/storage/MessageStore.h create mode 100644 src/storage/SDStore.cpp create mode 100644 src/storage/SDStore.h create mode 100644 src/transport/BLEInterface.cpp create mode 100644 src/transport/BLEInterface.h create mode 100644 src/transport/BLESideband.cpp create mode 100644 src/transport/BLESideband.h create mode 100644 src/transport/LoRaInterface.cpp create mode 100644 src/transport/LoRaInterface.h create mode 100644 src/transport/TCPClientInterface.cpp create mode 100644 src/transport/TCPClientInterface.h create mode 100644 src/transport/WiFiInterface.cpp create mode 100644 src/transport/WiFiInterface.h create mode 100644 src/ui/StatusBar.cpp create mode 100644 src/ui/StatusBar.h create mode 100644 src/ui/TabBar.cpp create mode 100644 src/ui/TabBar.h create mode 100644 src/ui/Theme.cpp create mode 100644 src/ui/Theme.h create mode 100644 src/ui/UIManager.cpp create mode 100644 src/ui/UIManager.h create mode 100644 src/ui/screens/BootScreen.cpp create mode 100644 src/ui/screens/BootScreen.h create mode 100644 src/ui/screens/HelpOverlay.cpp create mode 100644 src/ui/screens/HelpOverlay.h create mode 100644 src/ui/screens/HomeScreen.cpp create mode 100644 src/ui/screens/HomeScreen.h create mode 100644 src/ui/screens/MapScreen.cpp create mode 100644 src/ui/screens/MapScreen.h create mode 100644 src/ui/screens/MessageView.cpp create mode 100644 src/ui/screens/MessageView.h create mode 100644 src/ui/screens/MessagesScreen.cpp create mode 100644 src/ui/screens/MessagesScreen.h create mode 100644 src/ui/screens/NodesScreen.cpp create mode 100644 src/ui/screens/NodesScreen.h create mode 100644 src/ui/screens/SettingsScreen.cpp create mode 100644 src/ui/screens/SettingsScreen.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c421542 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,81 @@ +name: PlatformIO Build + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install PlatformIO + run: pip install platformio esptool + + - name: Build firmware + run: pio run -e ratdeck_915 + + - name: Create merged firmware binary + run: | + python3 -m esptool --chip esp32s3 merge_bin \ + --flash_mode qio \ + --flash_size 16MB \ + --flash_freq 80m \ + -o ratdeck-firmware.bin \ + 0x0000 .pio/build/ratdeck_915/bootloader.bin \ + 0x8000 .pio/build/ratdeck_915/partitions.bin \ + 0xe000 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + 0x10000 .pio/build/ratdeck_915/firmware.bin + + - name: Upload firmware artifact + uses: actions/upload-artifact@v4 + with: + name: ratdeck-firmware + path: ratdeck-firmware.bin + + release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install PlatformIO + run: pip install platformio esptool + + - name: Build firmware + run: pio run -e ratdeck_915 + + - name: Create merged firmware binary + run: | + python3 -m esptool --chip esp32s3 merge_bin \ + --flash_mode qio \ + --flash_size 16MB \ + --flash_freq 80m \ + -o ratdeck-firmware.bin \ + 0x0000 .pio/build/ratdeck_915/bootloader.bin \ + 0x8000 .pio/build/ratdeck_915/partitions.bin \ + 0xe000 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + 0x10000 .pio/build/ratdeck_915/firmware.bin + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: ratdeck-firmware.bin + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..439bea3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch +build/ +*.bin +*.elf +__pycache__ +*.pyc +.DS_Store +CLAUDE.md +MANUAL.md +RESOURCES.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f331010 --- /dev/null +++ b/README.md @@ -0,0 +1,297 @@ +# Ratdeck + +Standalone [Reticulum](https://reticulum.network/) transport node + [LXMF](https://github.com/markqvist/LXMF) encrypted messenger, built for the [LilyGo T-Deck Plus](https://www.lilygo.cc/products/t-deck-plus) with integrated SX1262 LoRa radio. + +Not an RNode. Not a gateway. A fully self-contained mesh node with a keyboard, touchscreen, trackball, and LoRa radio — no host computer required. + +## What This Does + +Ratdeck turns a T-Deck Plus into a Reticulum mesh node that can: + +- **Send and receive encrypted messages** over LoRa (LXMF protocol, Ed25519 signatures) +- **Discover other nodes** automatically via Reticulum announces +- **Bridge LoRa to WiFi** so desktop Reticulum instances can reach the mesh +- **Connect to remote Reticulum nodes** over TCP (e.g., `rns.beleth.net`) +- **Store messages and contacts** on flash and SD card with automatic backup +- **Configure everything on-device** — no config files, no host tools + +The device runs [microReticulum](https://github.com/attermann/microReticulum) (a C++ port of the Reticulum stack) directly on the ESP32-S3, with a register-level SX1262 LoRa driver. + +## Features + +| Category | Details | +|----------|---------| +| **Networking** | Reticulum transport node, path discovery, announce propagation, auto-announce every 5 min | +| **Messaging** | LXMF encrypted messages, Ed25519 signatures, delivery tracking, per-conversation storage | +| **LoRa Radio** | SX1262 at 915 MHz, configurable SF (5-12), BW (7.8-500 kHz), CR (4/5-4/8), TX power (2-22 dBm) | +| **WiFi** | AP mode (TCP server on :4242) or STA mode (TCP client to remote nodes) — not concurrent | +| **BLE** | NimBLE Sideband interface for Reticulum over Bluetooth | +| **Storage** | Dual-backend: LittleFS (7.8 MB on flash) + FAT32 microSD, atomic writes, identity backup | +| **Display** | 320x240 IPS TFT via LovyanGFX, signal green on black, double-buffered sprite rendering | +| **Input** | Full QWERTY keyboard (ESP32-C3 I2C), GT911 capacitive touchscreen, optical trackball | +| **Audio** | I2S codec, notification sounds for messages, announces, errors, boot chime | +| **GPS** | UBlox MIA-M10Q GNSS (pins defined, v1.1) | +| **Power** | Screen dim/off/wake on input, configurable timeouts, battery % in status bar | +| **Reliability** | Boot loop recovery (NVS counter, forces WiFi OFF after 3 failures) | +| **Diagnostics** | Ctrl+D full dump, Ctrl+T radio test packet, Ctrl+R 5-second RSSI monitor | + +## Hardware + +| Component | Part | Notes | +|-----------|------|-------| +| **Board** | LilyGo T-Deck Plus | ESP32-S3, 16MB flash, PSRAM, 320x240 IPS TFT, QWERTY keyboard, trackball, touchscreen | +| **Radio** | Integrated SX1262 | 915 MHz ISM, TCXO 1.8V, DIO2 RF switch, shared SPI bus | +| **Storage** | microSD card | Optional but recommended. FAT32, any size | +| **USB** | USB-C | USB-Serial/JTAG. Port: `/dev/cu.usbmodem*` (macOS), `/dev/ttyACM*` (Linux) | + +See [docs/PINMAP.md](docs/PINMAP.md) for the full GPIO pin map. + +## Prerequisites + +| Requirement | Version | Install | +|-------------|---------|---------| +| **Python** | 3.12+ | [python.org](https://www.python.org/downloads/) or your package manager | +| **PlatformIO Core** | 6.x | `pip install platformio` | +| **Git** | any | Your package manager | +| **USB driver** | — | None needed on macOS/Linux (ESP32-S3 USB-Serial/JTAG is built-in) | + +PlatformIO automatically downloads the ESP32-S3 toolchain, Arduino framework, and all library dependencies on first build. + +## Build and Flash + +```bash +# Clone +git clone https://github.com/defidude/Ratdeck.git +cd Ratdeck + +# Build (first build takes ~2 min to download toolchain + deps) +python3 -m platformio run -e ratdeck_915 + +# Flash (plug in T-Deck Plus via USB-C) +python3 -m platformio run -e ratdeck_915 -t upload --upload-port /dev/cu.usbmodem* +``` + +> If `pio` is not on your PATH after install, use `python3 -m platformio` everywhere. + +### Alternative: esptool + +PlatformIO's default baud sometimes fails over USB-Serial/JTAG. esptool at 460800 is more reliable: + +```bash +python3 -m esptool --chip esp32s3 --port /dev/cu.usbmodem* --baud 460800 \ + --before default-reset --after hard-reset \ + write_flash -z 0x10000 .pio/build/ratdeck_915/firmware.bin +``` + +See [docs/BUILDING.md](docs/BUILDING.md) for merged binaries, build flags, partition table, and CI/CD details. + +## First Boot + +1. Plug in or power on the T-Deck Plus +2. Boot animation with progress bar (~3 seconds) +3. SX1262 radio initializes at 915 MHz +4. SD card checked, `/ratdeck/` directories auto-created +5. Reticulum identity generated (Ed25519 keypair, persisted to flash + SD) +6. WiFi AP starts: `ratdeck-XXXX` (password: `ratspeak`) +7. Initial announce broadcast to the mesh +8. Home screen: identity hash, transport status, radio info, uptime + +## UI Layout + +``` ++------------------------------------------+ +| [87%] [Ratspeak.org] [LoRa] | Status bar: battery, transport mode, radio ++------------------------------------------+ +| | +| CONTENT AREA | Screens: Home, Messages, Nodes, Map, Settings +| 320 x 240 | +| | ++------------------------------------------+ +| [Home] [Msgs] [Nodes] [Map] [Setup] | Tab bar with unread badges ++------------------------------------------+ +``` + +**Theme**: Signal green (#00FF41) on black. 320x240 pixels. LovyanGFX double-buffered sprite rendering. + +## Keyboard and Hotkeys + +### Hotkeys (Ctrl+key) + +| Shortcut | Action | +|----------|--------| +| Ctrl+H | Toggle help overlay (shows all hotkeys on screen) | +| Ctrl+M | Jump to Messages tab | +| Ctrl+N | Compose new message | +| Ctrl+S | Jump to Settings tab | +| Ctrl+A | Force announce to network (immediate) | +| Ctrl+D | Dump full diagnostics to serial | +| Ctrl+T | Send radio test packet | +| Ctrl+R | RSSI monitor (5 seconds continuous sampling) | + +### Navigation + +| Key | Action | +|-----|--------| +| Trackball | Scroll / navigate | +| Touch | Tap to select UI elements | +| Enter | Select / confirm / send message | +| Esc | Back / cancel | +| Backspace | Delete character in text input | + +## WiFi and Networking + +Three WiFi modes, configured in Settings: + +### OFF Mode + +No WiFi. Saves power and heap. LoRa-only operation. + +### AP Mode (default) + +Creates a WiFi hotspot named `ratdeck-XXXX` (password: `ratspeak`). Runs a TCP server on port 4242 with HDLC framing. + +**Bridge to desktop Reticulum**: Connect your laptop to the `ratdeck-XXXX` network, then add to your Reticulum config (`~/.reticulum/config`): + +```ini +[[ratdeck]] + type = TCPClientInterface + target_host = 192.168.4.1 + target_port = 4242 +``` + +Now your desktop `rnsd` can reach the LoRa mesh through Ratdeck. + +### STA Mode + +Connects to an existing WiFi network. Establishes outbound TCP connections to configured Reticulum endpoints. + +**Setup**: +1. Ctrl+S, WiFi, Mode: **STA** +2. Enter your WiFi SSID and password +3. Add TCP endpoints: e.g., `rns.beleth.net` port `4242`, auto-connect enabled +4. Save — Ratdeck connects to your WiFi, then opens TCP links to the configured hosts + +## LoRa Radio + +### Default Configuration + +| Parameter | Default | Range | +|-----------|---------|-------| +| Frequency | 915 MHz | Hardware-dependent | +| Spreading Factor | SF9 | SF5-SF12 | +| Bandwidth | 125 kHz | 7.8 kHz - 500 kHz | +| Coding Rate | 4/5 | 4/5 - 4/8 | +| TX Power | 22 dBm | 2-22 dBm | +| Preamble | 18 symbols | Configurable | +| Sync Word | 0x1424 | Reticulum standard | +| Max Packet | 255 bytes | SX1262 hardware limit | + +All parameters configurable via Settings. Changes take effect immediately and persist across reboots. + +## SD Card + +Optional microSD card (FAT32, any size). Provides backup storage and extended capacity beyond the LittleFS partition. + +### Directory Structure (auto-created on first boot) + +``` +/ratdeck/ + config/ + user.json Runtime settings (radio, WiFi, display, audio) + messages/ + / Per-conversation message history (JSON) + contacts/ Discovered Reticulum nodes + identity/ + identity.key Ed25519 keypair backup +``` + +## Dependencies + +All automatically managed by PlatformIO — no manual installation needed: + +| Library | Version | Purpose | +|---------|---------|---------| +| [microReticulum](https://github.com/attermann/microReticulum) | git HEAD | Reticulum protocol stack (C++ port) | +| [Crypto](https://github.com/attermann/Crypto) | git HEAD | Ed25519, X25519, AES-128, SHA-256, HMAC | +| [ArduinoJson](https://github.com/bblanchon/ArduinoJson) | ^7.4.2 | JSON serialization for config and message storage | +| [LovyanGFX](https://github.com/lovyan03/LovyanGFX) | ^1.1.16 | Display driver — SPI, DMA, sprite double-buffering | +| [NimBLE-Arduino](https://github.com/h2zero/NimBLE-Arduino) | ^2.1 | BLE stack for Sideband interface | + +### Build Toolchain + +| Component | Version | Notes | +|-----------|---------|-------| +| PlatformIO | espressif32@6.7.0 | ESP-IDF + Arduino framework | +| Board | esp32-s3-devkitc-1 | Generic ESP32-S3, 16MB flash, PSRAM | +| Arduino Core | ESP32 Arduino 2.x | C++17, exceptions enabled | + +## Flash Memory Layout + +16MB flash, partitioned for OTA support: + +| Partition | Offset | Size | Purpose | +|-----------|--------|------|---------| +| nvs | 0x9000 | 20 KB | Boot counter, WiFi credentials | +| otadata | 0xE000 | 8 KB | OTA boot selection | +| app0 | 0x10000 | 4 MB | Active firmware | +| app1 | 0x410000 | 4 MB | OTA update slot (reserved) | +| littlefs | 0x810000 | 7.8 MB | Identity, config, messages, transport paths | +| coredump | 0xFF0000 | 64 KB | ESP-IDF crash dump | + +## Project Structure + +``` +Ratdeck/ + src/ + main.cpp Entry point: setup() + main loop + config/ BoardConfig.h (pins), Config.h (compile-time), UserConfig.* (runtime JSON) + radio/ SX1262.* (register-level driver), RadioConstants.h + hal/ Display (LovyanGFX), TouchInput (GT911), Trackball, Keyboard, Power, GPS, Audio + input/ InputManager (unified input), HotkeyManager (Ctrl+key dispatch) + ui/ UIManager, StatusBar, TabBar, Theme + screens/ Boot, Home, Messages, MessageView, Nodes, Map, Settings, HelpOverlay + reticulum/ ReticulumManager, AnnounceManager, LXMFManager, LXMFMessage + transport/ LoRaInterface, WiFiInterface, TCPClientInterface, BLEInterface, BLESideband + storage/ FlashStore (LittleFS), SDStore (FAT32), MessageStore (dual) + power/ PowerManager (dim/off/wake) + audio/ AudioNotify (boot, message, announce, error sounds) + docs/ BUILDING, PINMAP, TROUBLESHOOTING, DEVELOPMENT, ARCHITECTURE, QUICKSTART, HOTKEYS + platformio.ini Build configuration (single env: ratdeck_915) + partitions_16MB.csv Flash partition table + .github/workflows/ CI: build on push, release merged binary on tag +``` + +## Documentation + +| Document | Contents | +|----------|----------| +| [Quick Start](docs/QUICKSTART.md) | First build, first boot, navigation, WiFi setup, SD card | +| [Building](docs/BUILDING.md) | Build commands, flashing, merged binaries, CI/CD, build flags | +| [Pin Map](docs/PINMAP.md) | Full GPIO assignments for all peripherals | +| [Hotkeys](docs/HOTKEYS.md) | Complete keyboard reference | +| [Architecture](docs/ARCHITECTURE.md) | Layer diagram, directory tree, design decisions | +| [Development](docs/DEVELOPMENT.md) | How to add screens, hotkeys, settings, transports | +| [Troubleshooting](docs/TROUBLESHOOTING.md) | Radio, build, boot loop, storage, WiFi issues | + +## Current Status + +**v1.0.0** — Working on hardware. + +| Subsystem | Status | +|-----------|--------| +| LoRa radio | Working — TX/RX verified | +| WiFi AP | Working — TCP server, HDLC framing, bridges to desktop rnsd | +| WiFi STA + TCP | Working — connects to remote Reticulum nodes | +| LXMF messaging | Working — send/receive/store with Ed25519 signatures | +| Node discovery | Working — automatic announce processing | +| SD card storage | Working — dual-backend with atomic writes | +| Settings | Working — full on-device configuration | +| Touchscreen | Working — GT911 capacitive touch | +| Trackball | Working — optical navigation | +| BLE Sideband | Working — NimBLE interface | +| GPS | Pins defined — v1.1 | +| OTA updates | Partition reserved — not implemented | + +## License + +GPL-3.0 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..fe388bb --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,151 @@ +# Ratputer — Architecture + +## Overview + +Ratputer is a standalone Reticulum transport node with LXMF messaging, built for the M5Stack Cardputer Adv with Cap LoRa-1262 radio module. + +## Layer Diagram + +``` +┌─────────────────────────────────────┐ +│ UI Layer (M5Canvas) │ +│ Screens: Home, Msgs, Nodes, Setup │ +│ Widgets: ScrollList, TextInput │ +│ StatusBar, TabBar, HelpOverlay │ +├─────────────────────────────────────┤ +│ Application Layer │ +│ LXMFManager AnnounceManager │ +│ UserConfig AudioNotify │ +│ PowerManager MessageStore │ +├─────────────────────────────────────┤ +│ Reticulum Layer │ +│ ReticulumManager (microReticulum) │ +│ Identity, Destination, Transport │ +├─────────────────────────────────────┤ +│ Transport Layer │ +│ LoRaInterface WiFiInterface │ +│ TCPClientInterface BLEStub │ +├─────────────────────────────────────┤ +│ Storage Layer │ +│ FlashStore (LittleFS) │ +│ SDStore (FAT32 microSD) │ +├─────────────────────────────────────┤ +│ Hardware Layer │ +│ SX1262 Radio M5Cardputer │ +│ LittleFS ESP32-S3 │ +└─────────────────────────────────────┘ +``` + +## Directory Structure + +``` +src/ +├── main.cpp Main entry point (setup + loop) +├── config/ +│ ├── BoardConfig.h Pin definitions, hardware constants +│ ├── Config.h Compile-time settings, feature flags, paths +│ └── UserConfig.* Runtime settings (JSON, dual SD+flash backend) +├── radio/ +│ ├── SX1262.* SX1262 LoRa driver (register-level) +│ └── RadioConstants.h Register definitions +├── input/ +│ ├── Keyboard.* M5Cardputer keyboard wrapper +│ └── HotkeyManager.* Ctrl+key dispatch +├── ui/ +│ ├── Theme.h Color palette, layout metrics +│ ├── UIManager.* Canvas rendering, screen stack +│ ├── Screen.h Abstract base class +│ ├── StatusBar.* Top bar (battery, transport, LoRa) +│ ├── TabBar.* Bottom tab navigation +│ ├── screens/ Per-tab screen implementations +│ ├── widgets/ Reusable UI components +│ └── assets/ Boot logo +├── reticulum/ +│ ├── ReticulumManager.* microReticulum integration +│ ├── AnnounceManager.* Node discovery, contact persistence +│ ├── LXMFManager.* LXMF messaging protocol +│ └── LXMFMessage.* Message format (MsgPack wire, JSON storage) +├── transport/ +│ ├── LoRaInterface.* SX1262 ↔ Reticulum bridge (1-byte header) +│ ├── WiFiInterface.* WiFi AP transport, TCP server on :4242 +│ ├── TCPClientInterface.* WiFi STA transport, TCP client to remote nodes +│ └── BLEStub.* BLE advertising placeholder (disabled) +├── storage/ +│ ├── FlashStore.* LittleFS with atomic writes +│ ├── SDStore.* SD card (FAT32) with atomic writes + wipe +│ └── MessageStore.* Per-conversation storage (dual: flash + SD) +├── power/ +│ └── PowerManager.* Screen dim/off/wake +└── audio/ + └── AudioNotify.* Notification sounds +``` + +## Data Flow + +### Incoming LoRa Packet + +``` +SX1262 IRQ (DIO1) → SX1262::receive() reads FIFO + → LoRaInterface::loop() strips 1-byte header + → RNS::InterfaceImpl::receive_incoming() + → RNS::Transport processes packet + ├── Announce → AnnounceManager callback → UI update + ├── LXMF data → LXMFManager → MessageStore (flash + SD) → UI notification + └── Path/link → Transport table update → persist to flash +``` + +### Outgoing LXMF Message + +``` +User types message → MessageView → LXMFManager::send() + → Pack: source_hash(16) + msgpack([ts, content, title, fields]) + Ed25519 sig(64) + → RNS::Packet → RNS::Transport selects interface + ├── LoRaInterface → prepend 1-byte header → SX1262::beginPacket/endPacket + └── WiFi/TCPClient → HDLC frame (0x7E delimit, 0x7D escape) → TCP socket +``` + +### Config Save + +``` +SettingsScreen → UserConfig::save(sd, flash) + ├── serialize to JSON string + ├── SDStore::writeAtomic("/ratputer/config/user.json") → .tmp → verify → .bak → rename + └── FlashStore::writeAtomic("/config/user.json") → .tmp → verify → .bak → rename +``` + +## Key Design Decisions + +### Radio Driver +Extracted from RNode_Firmware_CE, stripped of multi-interface and CSMA/CA. Custom SPI (HSPI) with TCXO 3.0V configuration. IRQ stale latch fix applied to prevent DCD lockup after first TX. + +### Display +Double-buffered M5Canvas sprite (240×135 RGB565). All rendering goes through UIManager which handles status bar, content area clipping, tab bar, and overlay. + +### Reticulum Integration +microReticulum C++ library with LittleFS-backed filesystem. Device runs as a Transport Node with LoRa and WiFi/TCP interfaces registered. + +### LXMF Messages +Wire format: `source_hash(16) + msgpack([timestamp, content, title, fields]) + signature(64)`. Direct packet delivery for messages under MDU. Stored as JSON per-conversation in flash and SD. + +### WiFi Transport +Three separate modes (not concurrent): +- **OFF**: No WiFi — saves power and ~20KB heap +- **AP**: Creates hotspot, TCP server on port 4242 with HDLC framing (0x7E delimiters, 0x7D escape) +- **STA**: Connects to existing network, TCP client connections to configured endpoints + +AP+STA concurrent mode was removed — it consumed too much heap and caused instability. + +### TCP Client Transport +Outbound TCP connections to remote Reticulum nodes. Created on first WiFi STA connection, auto-reconnect on disconnect. Uses same HDLC framing as WiFi AP. + +### Dual-Backend Storage +FlashStore (LittleFS) is the primary store. SDStore (FAT32 microSD) provides backup and extended capacity. Both use atomic writes (.tmp → verify → .bak → rename) to prevent corruption on power loss. UserConfig, MessageStore, and AnnounceManager write to both backends. + +### Boot Loop Recovery +NVS counter tracks consecutive boot failures. After 3 failures, WiFi is forced OFF on next boot (WiFi init is the most common crash source). Counter resets to 0 at end of successful setup(). + +### Transport Reference Stability +`RNS::Transport::_interfaces` stores `Interface&` references (not copies). All `RNS::Interface` wrappers must outlive the transport — stored in `std::list` (not vector, which would invalidate references on reallocation). + +### Power Management +Three states: Active → Dimmed (25% brightness) → Screen Off. Wakes on any keypress. Configurable timeouts via Settings. diff --git a/docs/BUILDING.md b/docs/BUILDING.md new file mode 100644 index 0000000..27e2a73 --- /dev/null +++ b/docs/BUILDING.md @@ -0,0 +1,160 @@ +# Ratputer — Build & Flash Reference + +## Prerequisites + +- **Python 3.12+** (for PlatformIO and esptool) +- **PlatformIO Core** (CLI): `pip install platformio` +- **esptool** (usually installed with PlatformIO, or `pip install esptool`) +- **Git** + +No USB drivers needed on macOS or Linux — the ESP32-S3's USB-Serial/JTAG interface is natively supported. + +> **Note**: PlatformIO may not be on your PATH after pip install. Use `python3 -m platformio` if `pio` is not found. This applies to all `pio` commands throughout this document. + +## Build + +```bash +python3 -m platformio run -e ratputer_915 +``` + +Output binary: `.pio/build/ratputer_915/firmware.bin` + +First build downloads all dependencies automatically (M5Unified, M5GFX, M5Cardputer, microReticulum, Crypto, ArduinoJson). + +## Flash + +### Via PlatformIO (simple) + +```bash +python3 -m platformio run -e ratputer_915 -t upload --upload-port /dev/cu.usbmodem* +``` + +### Via esptool (more reliable) + +PlatformIO defaults to 921600 baud which sometimes fails. esptool at 460800 is more reliable: + +```bash +python3 -m esptool --chip esp32s3 --port /dev/cu.usbmodem* --baud 460800 \ + --before default-reset --after hard-reset \ + write-flash -z 0x10000 .pio/build/ratputer_915/firmware.bin +``` + +### Creating a Merged Binary + +A merged binary includes bootloader + partition table + app in one file for clean flashing: + +```bash +python3 -m esptool --chip esp32s3 merge-bin \ + --output ratputer_merged.bin \ + --flash-mode dio --flash-size 8MB \ + 0x0 ~/.platformio/packages/framework-arduinoespressif32/tools/sdk/esp32s3/bin/bootloader_dio_80m.bin \ + 0x8000 .pio/build/ratputer_915/partitions.bin \ + 0xe000 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + 0x10000 .pio/build/ratputer_915/firmware.bin + +python3 -m esptool --chip esp32s3 --port /dev/cu.usbmodem* --baud 460800 \ + --before default-reset --after hard-reset \ + write-flash 0x0 ratputer_merged.bin +``` + +## Serial Monitor + +```bash +python3 -m platformio device monitor -b 115200 +``` + +Or with any serial terminal at 115200 baud. + +### Serial WIPE Command + +Within the first 500ms of boot, send `WIPE` over serial to wipe the SD card's `/ratputer/` directory. Useful for factory reset of stored messages, contacts, and config. + +## USB Port Identification + +The ESP32-S3 on Cardputer Adv uses USB-Serial/JTAG (not a separate UART chip): + +| State | Port Name | Notes | +|-------|-----------|-------| +| Firmware mode | `/dev/cu.usbmodem` | Normal operation, serial + flashing | +| Bootloader mode | `/dev/cu.usbmodem5101` | Hold G0 during boot, esptool only | + +The firmware-mode port name includes the chip's unique ID (e.g., `/dev/cu.usbmodem3C0F02E81B4C1`). Use `/dev/cu.usbmodem*` glob to match either. + +## Build Flags + +From `platformio.ini`: + +| Flag | Purpose | +|------|---------| +| `-fexceptions` | Enable C++ exceptions (required by microReticulum) | +| `-DRATPUTER=1` | Main feature flag — guards all Ratputer-specific code | +| `-DARDUINO_USB_CDC_ON_BOOT=1` | USB CDC serial on boot (USBMode=default) | +| `-DARDUINO_USB_MODE=1` | USB mode 1 = USB-Serial/JTAG (not native CDC) | +| `-DRNS_USE_FS` | microReticulum: use filesystem for persistence | +| `-DRNS_PERSIST_PATHS` | microReticulum: persist transport paths to flash | +| `-DMSGPACK_USE_BOOST=OFF` | Disable Boost dependency in MsgPack | + +`build_unflags = -fno-exceptions` removes the Arduino default no-exceptions flag. + +## Partition Table + +`partitions_8MB_ota.csv` — 8MB flash layout with OTA support: + +| Name | Type | Offset | Size | Purpose | +|------|------|--------|------|---------| +| nvs | data/nvs | 0x9000 | 20 KB | Non-volatile storage (boot counter, WiFi creds) | +| otadata | data/ota | 0xE000 | 8 KB | OTA boot selection | +| app0 | app/ota_0 | 0x10000 | 3 MB | Primary firmware | +| app1 | app/ota_1 | 0x310000 | 3 MB | OTA update slot (reserved) | +| littlefs | data/spiffs | 0x610000 | 1.875 MB | LittleFS — identity, config, messages, paths | +| coredump | data/coredump | 0x7F0000 | 64 KB | ESP-IDF core dump on crash | + +## CI/CD + +GitHub Actions workflow (`.github/workflows/build.yml`): + +- **Build**: Triggers on push to `main` and PRs. Runs `pio run`, uploads `firmware.bin` as artifact. +- **Release**: Triggers on `v*` tags. Builds firmware and creates a GitHub Release with the binary attached. + +## Dependencies + +All managed by PlatformIO's `lib_deps`: + +| Library | Source | Purpose | +|---------|--------|---------| +| microReticulum | github.com/attermann/microReticulum | Reticulum protocol (C++) | +| Crypto | github.com/attermann/Crypto | Ed25519, X25519, AES, SHA256 | +| ArduinoJson | bblanchon/ArduinoJson ^7.4.2 | Config serialization | +| M5Unified | m5stack/M5Unified | Hardware abstraction | +| M5GFX | m5stack/M5GFX | Display + canvas rendering | +| M5Cardputer | m5stack/M5Cardputer | Keyboard (TCA8418) | + +## Erasing Flash + +To completely erase the ESP32-S3 flash (useful if LittleFS is corrupted or you want a clean start): + +```bash +python3 -m esptool --chip esp32s3 --port /dev/cu.usbmodem* erase-flash +``` + +After erasing, you must reflash the firmware. LittleFS will auto-format on first boot, and a new identity will be generated. + +## Common Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| `A]Fatal error occurred: Could not open port` | Device not connected or wrong port | Check USB cable, try `/dev/cu.usbmodem*` glob | +| `Timed out waiting for packet header` | Baud rate too high for USB-Serial/JTAG | Use `--baud 460800` with esptool | +| `No such option: --upload-port` | Old PlatformIO version | `pip install -U platformio` | +| `ImportError: No module named platformio` | PlatformIO not installed for this Python | `pip install platformio` or use the correct `python3` | +| `pio: command not found` | PlatformIO not on PATH | Use `python3 -m platformio` instead | +| `Error: Bootloader binary size ... exceeds` | Partition mismatch | Ensure `partitions_8MB_ota.csv` is present in repo root | + +## macOS vs Linux Ports + +| OS | Firmware Port | Bootloader Port | +|----|---------------|-----------------| +| macOS | `/dev/cu.usbmodem` | `/dev/cu.usbmodem5101` | +| Linux | `/dev/ttyACM0` (typical) | `/dev/ttyACM0` | + +On Linux, you may need to add your user to the `dialout` group: `sudo usermod -aG dialout $USER` (then log out and back in). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..5657849 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,348 @@ +# Ratputer — Developer Guide + +## Project Overview + +Ratputer is a **standalone Reticulum transport node** with LXMF messaging, built for the M5Stack Cardputer Adv with Cap LoRa-1262 radio. It is **NOT an RNode** — it does not speak KISS protocol. It runs its own Reticulum stack (microReticulum) directly on the device. + +Key characteristics: +- Standalone operation — no host computer required +- LoRa transport with 1-byte header framing (RNode-compatible on-air format) +- WiFi transport — AP mode (TCP server) or STA mode (TCP client) +- LXMF encrypted messaging with Ed25519 signatures +- Cyberpunk terminal UI with tabbed navigation +- JSON-based runtime configuration with SD card + flash dual-backend + +## Source Tree + +``` +src/ +├── main.cpp Setup (24-step init) + main loop (10 steps, 20 FPS) +├── config/ +│ ├── BoardConfig.h GPIO pins, SPI config, hardware constants +│ ├── Config.h Compile-time: version, feature flags, storage paths, limits +│ └── UserConfig.* Runtime settings: dual-backend JSON (SD primary, flash fallback) +├── radio/ +│ ├── SX1262.* Full SX1262 driver (register-level, extracted from RNode CE) +│ └── RadioConstants.h SX1262 register addresses and command bytes +├── input/ +│ ├── Keyboard.* M5Cardputer TCA8418 keyboard wrapper, key event generation +│ └── HotkeyManager.* Ctrl+key dispatch table, tab cycle callback +├── ui/ +│ ├── Theme.h Signal green (#00FF41) on black, layout metrics +│ ├── UIManager.* Canvas rendering loop, screen stack, boot/normal modes +│ ├── Screen.h Abstract base: handleKey(), render(), update() +│ ├── StatusBar.* Battery %, transport mode, LoRa indicator, announce flash +│ ├── TabBar.* Home/Msgs/Nodes/Setup tabs with unread badges +│ ├── screens/ +│ │ ├── BootScreen.* Animated boot with progress bar +│ │ ├── HomeScreen.* Identity hash, transport status, radio info, uptime +│ │ ├── MessagesScreen.* Conversation list with unread counts +│ │ ├── MessageView.* Single conversation view with text input +│ │ ├── NodesScreen.* Discovered Reticulum nodes with RSSI/SNR +│ │ ├── SettingsScreen.* Radio, WiFi, Display, Audio, About, Factory Reset +│ │ └── HelpOverlay.* Hotkey reference overlay (Ctrl+H toggle) +│ ├── widgets/ +│ │ ├── ScrollList.* Scrollable list with selection highlight +│ │ ├── TextInput.* Single-line text input with cursor +│ │ └── ProgressBar.* Boot progress and general-purpose bars +│ └── assets/ +│ └── BootLogo.h Embedded boot screen graphic +├── reticulum/ +│ ├── ReticulumManager.* microReticulum lifecycle, identity, announce, transport loop +│ ├── AnnounceManager.* Node discovery, contact persistence (SD + flash) +│ ├── LXMFManager.* LXMF send/receive, outgoing queue, delivery tracking +│ └── LXMFMessage.* Wire format: source(16) + msgpack + sig(64) +├── transport/ +│ ├── LoRaInterface.* SX1262 ↔ Reticulum bridge (InterfaceImpl), 1-byte header +│ ├── WiFiInterface.* WiFi AP, TCP server on port 4242, HDLC framing +│ ├── TCPClientInterface.* WiFi STA, TCP client to remote endpoints, HDLC framing +│ └── BLEStub.* BLE advertising placeholder (disabled, v1.1) +├── storage/ +│ ├── FlashStore.* LittleFS wrapper with atomic writes (.tmp→verify→.bak→rename) +│ ├── SDStore.* SD card (FAT32) with atomic writes, wipe, directory management +│ └── MessageStore.* Per-conversation message storage (dual: flash + SD backup) +├── power/ +│ └── PowerManager.* Screen dim/off/wake state machine, brightness control +└── audio/ + └── AudioNotify.* Notification sounds (boot, message, announce, error) +``` + +## Configuration System + +### Compile-Time (`Config.h`) + +Feature flags (`HAS_LORA`, `HAS_WIFI`, etc.), storage paths, protocol limits, power defaults. Changed only by editing source and recompiling. + +### Runtime (`UserConfig`) + +JSON-based settings persisted to storage. Schema defined by `UserSettings` struct in `UserConfig.h`: + +``` +{ + "loraFrequency": 915000000, + "loraSF": 7, + "loraBW": 500000, + "loraCR": 5, + "loraTxPower": 10, + "wifiMode": 1, // 0=OFF, 1=AP, 2=STA + "wifiAPSSID": "ratputer-XXXX", + "wifiAPPassword": "ratspeak", + "wifiSTASSID": "", + "wifiSTAPassword": "", + "tcpConnections": [{"host": "rns.beleth.net", "port": 4242, "autoConnect": true}], + "screenDimTimeout": 30, + "screenOffTimeout": 60, + "brightness": 255, + "audioEnabled": true, + "audioVolume": 80, + "displayName": "" +} +``` + +**Dual-backend persistence**: `UserConfig::load(SDStore&, FlashStore&)` reads from SD first (`/ratputer/config/user.json`), falls back to flash (`/config/user.json`). `save()` writes to both. + +## Transport Architecture + +### InterfaceImpl Pattern + +All transport interfaces inherit from `RNS::InterfaceImpl`: +- `start()` / `stop()` — lifecycle +- `send_outgoing(const RNS::Bytes& data)` — transmit +- `loop()` — poll for incoming data, call `receive_incoming()` to push up to Reticulum + +### HDLC Framing (WiFi + TCP) + +TCP connections use HDLC-like byte framing: +- `0x7E` — frame delimiter (start/end) +- `0x7D` — escape byte +- `0x20` — XOR mask for escaped bytes + +Any `0x7E` or `0x7D` in payload is escaped as `0x7D (byte ^ 0x20)`. + +### LoRa 1-Byte Header + +Every LoRa packet has a 1-byte header prepended: +- Upper nibble: random sequence number (for future split-packet tracking) +- Lower nibble: flags (`0x01` = split, not currently implemented) + +This matches the RNode on-air format, so Ratputer packets are structurally compatible with RNodes on the same frequency/modulation. + +## Reticulum Integration + +### microReticulum Library + +C++ port of the Python Reticulum stack. Provides `Identity`, `Destination`, `Transport`, `Packet`, and `Link` classes. + +Key integration points in `ReticulumManager`: +- `RNS::Reticulum::start()` — initialize the stack +- `RNS::Transport::register_interface()` — add LoRa, WiFi, TCP interfaces +- `RNS::Transport::register_announce_handler()` — node discovery callback +- `RNS::Reticulum::loop()` — process incoming/outgoing in main loop + +### Identity Persistence + +Device identity (Ed25519 keypair) is stored at `/identity/identity.key` in LittleFS with a backup copy on SD at `/ratputer/identity/identity.key`. If flash identity is lost (e.g., LittleFS format), the SD backup is restored automatically. + +### Path Persistence + +Transport paths are serialized to `/transport/paths.msgpack` in LittleFS periodically (every 60 seconds, configurable via `PATH_PERSIST_INTERVAL_MS`). + +## LXMF Protocol + +Wire format for direct LoRa delivery: + +``` +source_hash(16 bytes) + msgpack([timestamp, content, title, fields]) + signature(64 bytes) +``` + +- `source_hash` — 16-byte truncated SHA-256 of sender's public key +- MsgPack array: `[double timestamp, string content, string title, map fields]` +- `signature` — Ed25519 signature over `source_hash + msgpack_content` + +Messages under the MDU (Maximum Data Unit, ~254 bytes for LoRa) are sent as single direct packets. Larger messages would require link-based transfer (not yet implemented). + +Messages are stored as JSON per-conversation in both flash (`/messages//`) and SD (`/ratputer/messages//`). + +## Storage Architecture + +### FlashStore (LittleFS) + +Primary storage for all persistent data. 1.875 MB partition at offset 0x610000. + +**Atomic write pattern**: Write to `.tmp` → verify read-back → rename existing to `.bak` → rename `.tmp` to final path. Prevents corruption on power loss. + +### SDStore (FAT32) + +Secondary/backup storage on microSD card. Shares HSPI bus with LoRa radio. + +Directory structure: +``` +/ratputer/ +├── config/ +│ └── user.json Runtime settings backup +├── messages/ +│ └── / Per-conversation message history +├── contacts/ Discovered node info +└── identity/ + └── identity.key Identity key backup +``` + +### MessageStore (Dual Backend) + +Wraps FlashStore and SDStore to provide unified message access. Writes go to both backends; reads prefer SD (larger capacity), fall back to flash. + +## WiFi State Machine + +Three modes, selected in Settings: + +``` +RAT_WIFI_OFF (0) ──→ No WiFi, saves power + heap +RAT_WIFI_AP (1) ──→ Creates AP "ratputer-XXXX", TCP server on :4242 +RAT_WIFI_STA (2) ──→ Connects to configured network, TCP client connections +``` + +In STA mode, WiFi connection is non-blocking. TCP client interfaces are created on first successful connection and auto-reconnect if WiFi drops. + +Boot loop recovery forces WiFi to OFF if 3 consecutive boots fail. + +## How To: Add a New Screen + +1. Create `src/ui/screens/MyScreen.h` and `MyScreen.cpp` +2. Inherit from `Screen` — implement `handleKey()`, `render()`, optionally `update()` +3. In `render()`, use `m5canvas` to draw within the content area (y: 14 to 119) +4. Add a global instance in `main.cpp` +5. Wire it up: either add to `tabScreens[]` array or navigate to it from a hotkey/callback + +## How To: Add a New Hotkey + +1. In `main.cpp`, create a callback function: `void onHotkeyX() { ... }` +2. Register in `setup()`: `hotkeys.registerHotkey('x', "Description", onHotkeyX);` +3. Update `docs/HOTKEYS.md` and the help overlay text in `HelpOverlay.cpp` + +## How To: Add a Settings Submenu + +1. In `SettingsScreen.h`, add an enum value to the menu state +2. In `SettingsScreen.cpp`, add menu item text and handler +3. Add `render*()` and `handleKey*()` methods for the new submenu +4. Use `userConfig->save(sdStore, flash)` to persist changes + +## How To: Add a New Transport Interface + +1. Create a class inheriting from `RNS::InterfaceImpl` +2. Implement `start()`, `stop()`, `loop()`, `send_outgoing()` +3. In `loop()`, call `receive_incoming(data)` when data arrives +4. In `main.cpp`, construct the impl, wrap in `RNS::Interface`, register with `RNS::Transport` +5. Store the `RNS::Interface` wrapper in a persistent container (e.g., `std::list`) — Transport holds references + +## Initialization Sequence + +`setup()` runs these steps in order: + +1. M5Cardputer.begin() — display, keyboard, battery ADC +2. UI init + boot screen +3. Keyboard init +4. Register hotkeys (Ctrl+H/M/N/S/A/D/T/R) +5. Mount LittleFS (FlashStore) +6. Boot loop detection (NVS counter) +7. Radio init — SX1262 begin, configure modulation, enter RX +8. SD card init (shares HSPI, must be after radio) +9. Serial WIPE window (500ms) +10. Reticulum init — identity load/generate, transport start +11. MessageStore init (dual backend) +12. LXMF init + message callback +13. AnnounceManager init + contact load +14. Register announce handler with Transport +15. Load UserConfig (SD → flash fallback) +16. Boot loop recovery check (force WiFi OFF if triggered) +17. Apply saved radio settings +18. WiFi start (AP, STA, or OFF based on config) +19. BLE skip (disabled) +20. Power manager init + apply saved brightness/timeouts +21. Audio init + apply saved volume +22. Boot complete — switch to Home screen +23. Initial announce broadcast +24. Clear boot loop counter (NVS reset to 0) + +## Main Loop + +Runs at 20 FPS (50ms interval): + +1. `M5Cardputer.update()` — poll M5 hardware +2. Keyboard poll → hotkey dispatch → screen key handler → tab cycling +3. `rns.loop()` — Reticulum transport + radio RX processing +4. Auto-announce (every 5 minutes) +5. `lxmf.loop()` — outgoing message queue +6. WiFi STA connection handler + TCP client creation +7. `wifiImpl->loop()` — WiFi transport (AP server accepts, processes clients) +8. TCP client loops — reconnection, frame processing +9. `power.loop()` — dim/off state machine +10. Canvas render (if screen is on) + +## Memory Budget + +The ESP32-S3 has 512 KB SRAM. Typical free heap at runtime: + +| State | Free Heap | Notes | +|-------|-----------|-------| +| Boot complete (WiFi OFF) | ~170 KB | Baseline | +| Boot complete (WiFi AP) | ~150 KB | WiFi stack + TCP server | +| Boot complete (WiFi STA) | ~140 KB | WiFi stack + TCP clients | +| With BLE enabled | -50 KB | BLE disabled in v1.0 to save this | + +Key consumers: +- microReticulum transport tables: ~20–40 KB (scales with paths/links) +- M5Canvas sprite buffer: 240×135×2 = 64.8 KB (RGB565 double-buffer) +- ArduinoJson documents: ~4 KB per config parse +- SX1262 TX/RX buffers: 255 bytes each +- TCP RX buffer: 600 bytes per connection + +Monitor with `Ctrl+D` → `Free heap` or `ESP.getFreeHeap()` in code. + +## Debugging Tips + +### Serial output + +All subsystems log with `[TAG]` prefixes. Connect at 115200 baud. Key tags: `[BOOT]`, `[RADIO]`, `[LORA_IF]`, `[WIFI]`, `[LXMF]`, `[SD]`. + +### Radio debugging + +- `Ctrl+D` dumps all SX1262 registers — compare sync word, IQ polarity, LNA, and OCP with a known-working device +- `Ctrl+T` sends a test packet and reads back the FIFO — confirms the TX path end-to-end +- `Ctrl+R` samples RSSI for 5 seconds — if readings stay at -110 to -120 dBm while another device transmits, the RX front-end isn't receiving RF +- `DevErrors: 0x0040` = PLL lock failure → check TCXO voltage (must be 3.0V / 0x06) + +### Crash debugging + +ESP-IDF stores a core dump in the `coredump` partition (64 KB at 0x7F0000). To read it: + +```bash +python3 -m esptool --chip esp32s3 --port /dev/cu.usbmodem* read-flash 0x7F0000 0x10000 coredump.bin +python3 -m esp_coredump info_corefile -t raw -c coredump.bin .pio/build/ratputer_915/firmware.elf +``` + +### Common crash causes + +| Crash | Cause | Fix | +|-------|-------|-----| +| `LoadProhibited` at transport loop | Dangling `Interface&` reference | Store `RNS::Interface` in `std::list` (not vector, not local scope) | +| `Stack overflow` in task | Deep call chain in ISR or recursive render | Increase stack size or reduce nesting | +| `Guru Meditation` on WiFi init | Heap exhaustion | Disable BLE, reduce TCP connections, check for leaks | +| Boot loop (3+ failures) | WiFi or TCP init crash | Boot loop recovery auto-disables WiFi; fix root cause in Settings | + +## Compile-Time Limits + +These are defined in `Config.h` and can be adjusted: + +| Constant | Default | Purpose | +|----------|---------|---------| +| `RATPUTER_MAX_NODES` | 50 | Max discovered nodes in AnnounceManager | +| `RATPUTER_MAX_MESSAGES_PER_CONV` | 100 | Max messages stored per conversation | +| `FLASH_MSG_CACHE_LIMIT` | 20 | Keep only N most recent messages per conv in flash (SD has full history) | +| `RATPUTER_MAX_OUTQUEUE` | 20 | Max pending outgoing LXMF messages | +| `MAX_TCP_CONNECTIONS` | 4 | Max simultaneous TCP client connections | +| `TCP_RECONNECT_INTERVAL_MS` | 10000 | Retry interval for dropped TCP connections | +| `TCP_CONNECT_TIMEOUT_MS` | 5000 | Timeout for TCP connect() | +| `PATH_PERSIST_INTERVAL_MS` | 60000 | How often transport paths are saved to flash | +| `SCREEN_DIM_TIMEOUT_MS` | 30000 | Default screen dim timeout | +| `SCREEN_OFF_TIMEOUT_MS` | 60000 | Default screen off timeout | +| `ANNOUNCE_INTERVAL_MS` | 300000 | Auto-announce period (5 minutes, defined in main.cpp) | diff --git a/docs/HOTKEYS.md b/docs/HOTKEYS.md new file mode 100644 index 0000000..9d36063 --- /dev/null +++ b/docs/HOTKEYS.md @@ -0,0 +1,69 @@ +# Ratputer — Hotkey Reference + +All hotkeys use **Ctrl+key** combinations. + +| Shortcut | Action | +|----------|--------| +| Ctrl+H | Toggle help overlay | +| Ctrl+M | Jump to Messages tab | +| Ctrl+N | New message | +| Ctrl+S | Jump to Settings tab | +| Ctrl+A | Force announce to network | +| Ctrl+D | Dump diagnostics to serial | +| Ctrl+T | Send radio test packet (FIFO verification) | +| Ctrl+R | RSSI monitor (5-second continuous sampling) | + +## Navigation + +These keys match the physical arrow key positions on the Cardputer Adv keyboard: + +| Key | Action | +|-----|--------| +| `;` (semicolon) | Scroll up / previous item | +| `.` (period) | Scroll down / next item | +| `,` (comma) | Previous tab | +| `/` (slash) | Next tab | +| Enter | Select / confirm / send | +| Esc | Back / cancel | + +## Text Input + +When a text input field is active: +- Type normally to enter characters +- **Backspace** to delete +- **Enter** to submit +- **Esc** to cancel +- Double-tap **Aa** for caps lock + +## Tabs + +| Tab | Contents | +|-----|----------| +| Home | Identity, transport status, radio info, uptime | +| Msgs | Conversation list with unread badges | +| Nodes | Discovered Reticulum nodes | +| Setup | Settings, about, factory reset | + +## Serial Diagnostics + +**Ctrl+D** prints to serial (115200 baud): +- Identity hash, transport status, path/link counts +- Radio parameters (freq, SF, BW, CR, TX power, preamble) +- SX1262 register dump (sync word, IQ, LNA, OCP, TX clamp) +- Device errors, current RSSI +- Free heap, flash usage, uptime + +**Ctrl+T** sends a test packet with header `0xA0` and payload `RATPUTER_TEST_1234567890`, then reads back the FIFO buffer to verify the TX path. + +**Ctrl+R** samples RSSI continuously for 5 seconds, printing each reading. Transmit from another device during sampling to verify the RX front-end is hearing RF. + +## Settings Submenus (Ctrl+S) + +| Menu Item | Contents | +|-----------|----------| +| Radio | Frequency (Hz), spreading factor (5–12), bandwidth (7.8k–500k), coding rate (4/5–4/8), TX power (2–22 dBm) | +| WiFi | Mode (OFF/AP/STA), AP SSID + password, STA SSID + password, TCP endpoints list | +| Display | Brightness (0–255), dim timeout (seconds), off timeout (seconds) | +| Audio | Enable/disable notifications, volume (0–100%) | +| About | Firmware version, Reticulum identity hash, uptime, free heap, flash usage | +| Factory Reset | Clears config from flash + SD, reboots with defaults (identity and messages preserved) | diff --git a/docs/PINMAP.md b/docs/PINMAP.md new file mode 100644 index 0000000..dcaf145 --- /dev/null +++ b/docs/PINMAP.md @@ -0,0 +1,107 @@ +# Ratputer — Hardware Pin Map + +M5Stack Cardputer Adv (ESP32-S3) + Cap LoRa-1262 + +All pin definitions are in `src/config/BoardConfig.h`. + +## Bus Overview + +``` +ESP32-S3 + ├── HSPI (shared bus) ──┬── SX1262 LoRa (CS=5) + │ SCK=40 └── SD Card (CS=12) + │ MISO=39 + │ MOSI=14 + │ + ├── I2C ── TCA8418 Keyboard (SDA=8, SCL=9, INT=11) + │ + ├── UART (reserved) ── GNSS module (RX=15, TX=13) + │ + ├── USB-Serial/JTAG ── USB-C port (firmware + debug) + │ + └── M5Unified managed ──┬── ST7789V2 Display (SPI) + ├── ES8311 Audio Codec (I2S) + └── Battery ADC +``` + +## SX1262 LoRa Radio + +Uses **HSPI** (custom SPI bus, not the default VSPI): + +| Signal | GPIO | Notes | +|--------|------|-------| +| SCK | 40 | SPI clock | +| MISO | 39 | SPI data out (radio → ESP) | +| MOSI | 14 | SPI data in (ESP → radio) | +| CS | 5 | Chip select (active low) | +| IRQ | 4 | DIO1 interrupt (falling edge) | +| RST | 3 | Reset (active low, 100μs pulse) | +| BUSY | 6 | Poll before SPI transactions | +| RXEN | -1 | Not connected (DIO2 used as RF switch) | +| TXEN | -1 | Not connected | + +**Radio configuration:** +- TCXO voltage: 3.0V (`MODE_TCXO_3_0V_6X` = 0x06) +- DIO2 as RF switch: enabled +- SPI clock: 8 MHz + +## SD Card + +| Signal | GPIO | Notes | +|--------|------|-------| +| CS | 12 | Separate from LoRa CS (5) | + +Shares HSPI bus with LoRa radio (SCK=40, MISO=39, MOSI=14). Only one device active at a time — SD must be initialized **after** radio. + +## Keyboard (TCA8418) + +| Signal | GPIO | Notes | +|--------|------|-------| +| SDA | 8 | I2C data | +| SCL | 9 | I2C clock | +| INT | 11 | Active low, falling edge | + +Managed by the M5Cardputer library. The TCA8418 is a dedicated keyboard controller IC with built-in key matrix scanning and FIFO. + +## GNSS (Reserved — v1.1) + +| Signal | GPIO | Notes | +|--------|------|-------| +| RX | 15 | GPS TX → ESP RX | +| TX | 13 | ESP TX → GPS RX | + +UART at 115200 baud. Pins defined but no code path yet. + +## Display + +**ST7789V2** — 240×135 pixels, RGB565, SPI interface. + +Fully managed by M5Unified. No GPIO definitions needed in firmware — the M5Unified library auto-configures display pins based on board detection. + +## Audio + +**ES8311** codec + **NS4150B** amplifier. + +Fully managed by M5Unified. No GPIO definitions needed. + +## Battery + +ADC via M5Unified. 1750mAh LiPo, TP4057 charger IC. + +## SPI Bus Sharing + +The HSPI bus is shared between the SX1262 radio (CS=5) and the SD card (CS=12). Key constraints: + +1. **Initialize radio first** — SD card init must come after `radio.begin()` since the SPI bus is configured during radio init +2. **One active at a time** — pull CS high on the inactive device before talking to the other +3. **Radio has priority** — if a packet arrives during SD access, there may be a brief delay before the ISR fires + +## Hardware Constants + +| Constant | Value | Notes | +|----------|-------|-------| +| `MAX_PACKET_SIZE` | 255 | SX1262 maximum single packet | +| `SPI_FREQUENCY` | 8 MHz | SPI clock for SX1262 | +| `DISPLAY_WIDTH` | 240 | Pixels | +| `DISPLAY_HEIGHT` | 135 | Pixels | +| `GPS_BAUD` | 115200 | GNSS UART speed | diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..23af631 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,152 @@ +# Ratputer — Quick Start + +## Hardware Required + +- M5Stack Cardputer Adv (ESP32-S3, 8MB flash) +- Cap LoRa-1262 module (SX1262, 915 MHz) +- microSD card (optional, recommended) + +## Build & Flash + +```bash +# Install PlatformIO +pip install platformio + +# Clone and build +git clone https://github.com/defidude/Ratputer.git +cd Ratputer +python3 -m platformio run -e ratputer_915 + +# Flash to device (use glob to match USB port) +python3 -m platformio run -e ratputer_915 -t upload --upload-port /dev/cu.usbmodem* +``` + +> **Note**: If `pio` is not on your PATH, use `python3 -m platformio` instead. See [BUILDING.md](BUILDING.md) for esptool flashing and merged binary instructions. + +### USB Port + +The Cardputer Adv uses USB-Serial/JTAG — the port appears as `/dev/cu.usbmodem` in firmware mode. Use the `*` glob to match it. + +## First Boot + +1. Power on the Cardputer Adv +2. Boot animation plays with progress bar +3. Radio initializes at 915 MHz +4. SD card checked (auto-creates `/ratputer/` directories) +5. Reticulum transport node starts, identity generated +6. WiFi AP starts: `ratputer-XXXX` (password: `ratspeak`) +7. Home screen shows identity hash and status + +## Navigation + +Keys match the physical arrow positions on the Cardputer Adv keyboard: + +- **`;`** / **`.`**: Scroll up/down in lists +- **`,`** / **`/`**: Cycle between tabs (left/right) +- **Enter**: Select/confirm +- **Esc**: Back/cancel +- **Ctrl+key**: Hotkeys (press Ctrl+H for help) + +## Sending a Message + +1. Wait for another node to appear in the **Nodes** tab +2. Press **Ctrl+M** to go to Messages +3. Select a conversation or use **Ctrl+N** for new +4. Type your message and press **Enter** + +## WiFi Setup + +Default: AP mode with SSID `ratputer-XXXX`. + +### AP Mode (default) + +Connect a laptop to the `ratputer-XXXX` WiFi network, then configure `rnsd` with a TCPClientInterface pointing at `192.168.4.1:4242`. + +### STA Mode + +To connect Ratputer to your WiFi network: + +1. Press **Ctrl+S** → WiFi → Mode → STA +2. Enter your WiFi SSID and password +3. Save — device reconnects in STA mode +4. Add TCP endpoints (e.g., `rns.beleth.net:4242`) in WiFi → TCP Connections + +### WiFi OFF + +Select OFF in WiFi settings to disable WiFi entirely (saves power and ~20KB heap). + +## SD Card + +Insert a microSD card before powering on. The firmware auto-creates: + +``` +/ratputer/config/ Settings backup +/ratputer/messages/ Message archives +/ratputer/contacts/ Discovered nodes +/ratputer/identity/ Identity key backup +``` + +To wipe SD data: connect serial at 115200 baud, send `WIPE` within 500ms of boot. + +## Serial Monitor + +```bash +python3 -m platformio device monitor -b 115200 +``` + +Useful serial hotkeys: +- **Ctrl+D** on device: dump full diagnostics +- **Ctrl+T** on device: send radio test packet +- **Ctrl+R** on device: 5-second RSSI sampling + +## Settings + +Press **Ctrl+S** to access settings: +- **Radio**: frequency, spreading factor, bandwidth, coding rate, TX power +- **WiFi**: mode (OFF/AP/STA), AP SSID + password, STA SSID + password, TCP endpoints +- **Display**: brightness (0–255), dim timeout, off timeout +- **Audio**: enable/disable, volume (0–100) +- **About**: version, identity hash, uptime, factory reset + +Changes take effect immediately and persist to both flash and SD. + +## Connecting Two Ratputers + +Two Ratputers on the same LoRa settings will discover each other automatically: + +1. Power on both devices +2. Wait ~30 seconds for announces to propagate +3. Check the **Nodes** tab — the other device should appear with its identity hash, RSSI, and SNR +4. Select the node → opens a conversation in Messages +5. Type a message and press Enter + +Both devices must use the same frequency, spreading factor, bandwidth, and coding rate. The defaults (915 MHz, SF7, BW 500kHz, CR 4/5) work out of the box. + +## Connecting to a Desktop Reticulum Instance + +### Option A: AP Mode Bridge (Ratputer as hotspot) + +1. Leave Ratputer in AP mode (default) +2. On your laptop, connect to `ratputer-XXXX` (password: `ratspeak`) +3. Add to `~/.reticulum/config`: + ```ini + [[ratputer]] + type = TCPClientInterface + target_host = 192.168.4.1 + target_port = 4242 + ``` +4. Restart `rnsd` — your desktop is now on the LoRa mesh + +### Option B: STA Mode (Ratputer joins your WiFi) + +1. Switch Ratputer to STA mode (Ctrl+S → WiFi → Mode → STA) +2. Enter your WiFi SSID and password +3. On your laptop (same network), configure Ratputer as a TCP server interface in `~/.reticulum/config`: + ```ini + [[ratputer]] + type = TCPServerInterface + listen_ip = 0.0.0.0 + listen_port = 4242 + ``` +4. On Ratputer, add a TCP endpoint pointing to your laptop's IP and port 4242 +5. Both sides can now exchange Reticulum traffic over WiFi diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..bfba4d4 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,257 @@ +# Ratputer — Troubleshooting + +Collected hardware and software gotchas, organized by category. + +--- + +## Radio Issues + +### TCXO voltage must be 3.0V + +The Cap LoRa-1262 uses a TCXO (temperature-compensated crystal oscillator) that requires exactly 3.0V. This is configured as `MODE_TCXO_3_0V_6X` (0x06) in `BoardConfig.h`. + +**Symptom**: Radio reports online but can't synthesize frequencies. PLL lock fails, no TX or RX. + +**Diagnosis**: Check `Ctrl+D` diagnostics — if `DevErrors` shows `0x0040`, that's PLL lock failure. + +**Fix**: Verify `LORA_TCXO_VOLTAGE` is `0x06` in `BoardConfig.h`. + +### IRQ stale latch fix + +The SX1262's IRQ flags can become latched from previous operations. If stale flags persist, DCD (detect channel activity) gets stuck in "channel busy" state, CSMA blocks, and TX never fires. + +**Symptom**: First packet sends fine, then all subsequent TX attempts hang. DCD reports channel busy even with nothing transmitting. + +**Fix** (applied in `SX1262.cpp`): +- Clear all IRQ flags at the top of `receive()` before entering RX mode +- In `dcd()`, clear stale header error flags when preamble is not detected + +### `_txp` is a base class member + +The `_txp` (TX power) field is declared in `RadioInterface` base class (inherited from RNode firmware lineage). It cannot be initialized in the `sx126x` constructor initializer list — must set `_txp = 14` in the constructor body. + +**Symptom**: TX power reads as 0, which may cause silent failures or very weak transmission. + +### PA ramp time + +Use 40μs PA ramp time for the Cap LoRa-1262. This is set during `setTxParams()` in the SX1262 driver. Faster ramp times may cause spectral splatter; slower wastes time. + +--- + +## Build Issues + +### PlatformIO not on PATH + +After `pip install platformio`, the `pio` binary may not be in your shell's PATH. + +**Fix**: Use `python3 -m platformio` instead of `pio`: + +```bash +python3 -m platformio run -e ratputer_915 +python3 -m platformio run -e ratputer_915 -t upload +python3 -m platformio device monitor -b 115200 +``` + +### esptool baud rate + +PlatformIO defaults to 921600 baud for flashing, which sometimes fails with USB-Serial/JTAG. + +**Fix**: Use 460800 baud with esptool directly: + +```bash +python3 -m esptool --chip esp32s3 --port /dev/cu.usbmodem* --baud 460800 \ + write-flash -z 0x10000 .pio/build/ratputer_915/firmware.bin +``` + +### esptool hyphenated flags + +esptool deprecated underscored command names. Use hyphens: + +| Correct | Deprecated | +|---------|-----------| +| `merge-bin` | `merge_bin` | +| `write-flash` | `write_flash` | +| `--flash-mode` | `--flash_mode` | +| `--flash-size` | `--flash_size` | + +### USBMode must be `default` + +The build flag `ARDUINO_USB_MODE=1` selects USB-Serial/JTAG mode (not native CDC). + +**Symptom**: With `hwcdc` (USB_MODE=0), the native USB CDC peripheral doesn't enumerate on macOS in firmware mode. The port never appears. + +**Fix**: Keep `ARDUINO_USB_MODE=1` in `platformio.ini`. The port appears as `/dev/cu.usbmodem` (not `usbmodem5101`, which is bootloader-only). + +--- + +## Boot Issues + +### Boot loop detection and recovery + +Ratputer tracks consecutive boot failures in NVS (non-volatile storage, separate from LittleFS). If 3 consecutive boots fail to reach the end of `setup()`, WiFi is forced OFF on the next boot. + +**How it works**: +1. On each boot, NVS counter `bootc` increments +2. If `bootc >= 3`, `bootLoopRecovery = true` → WiFi forced to `RAT_WIFI_OFF` +3. At the end of successful `setup()`, counter resets to 0 + +**Manual recovery**: If the device is boot-looping, connect serial at 115200 baud and watch for the `[BOOT] Boot loop detected` message. The device should stabilize with WiFi off, then you can change WiFi settings. + +### Root cause: Transport reference stability + +The original boot loop was caused by `RNS::Transport::_interfaces` storing `Interface&` (references, NOT copies). A local `RNS::Interface iface(tcp)` declared inside a loop or function would go out of scope, creating a dangling reference. When Reticulum's transport loop tried to access it: `LoadProhibited` crash. + +**Fix**: Store TCP Interface wrappers in `std::list tcpIfaces` at global scope. Must use `std::list`, not `std::vector` — vector reallocation would move objects in memory, invalidating references held by Transport. + +### "auto detect board:24" in serial output + +This is the M5Unified library auto-detecting the board type. It's informational, not an error. The number 24 is M5's internal board ID for Cardputer Adv. + +--- + +## Storage Issues + +### LittleFS not mounting + +**Symptom**: `[E][vfs_api.cpp:24] open(): File system is not mounted` during boot. + +**Possible causes**: +- First boot after flash erase — LittleFS partition needs formatting +- Partition table mismatch — verify `partitions_8MB_ota.csv` matches flash layout +- `flash.begin()` may fail silently + +**Workaround**: FlashStore attempts `LittleFS.begin(true)` which auto-formats on first use. If it persists, erase the LittleFS partition and reflash. + +### SD card directories missing + +On first boot with a new SD card, the `/ratputer/` directory tree doesn't exist. + +**Fix**: `setup()` calls `sdStore.ensureDir()` for all required paths after SD init. If directories are still missing, check that SD CS (GPIO 12) is not conflicting with radio SPI. + +--- + +## Interop & RF Issues + +### Ratputer TX/RX verification (QA Round 9) + +- **Ratputer RX confirmed**: Received Heltec V3 RNode announce at -38 dBm, SNR 13.0 +- **Ratputer TX confirmed**: All SX1262 registers verified correct (SF7, BW 500kHz, CR 4/5, sync 0x1424, CRC on) +- **Heltec V3 RNode receive path**: Has never decoded a single LoRa packet from any source. Shows Ratputer RF as interference (-50 to -81 dBm) but can't decode. This is a Heltec issue, not Ratputer. + +### SX1262 calibration must run after TCXO is enabled + +Per SX1262 datasheet Section 13.1.12, if a TCXO is used, it **must** be enabled before calling `calibrate()` or `calibrate_image()`. Calibration locks to whichever oscillator is active. If TCXO isn't enabled yet, calibration uses the internal RC oscillator (~13MHz, ±3% tolerance). Each chip's RC has a different offset, so two devices end up synthesizing slightly different actual frequencies. The combined error can exceed the LoRa demodulation window. + +**Symptom**: TX completes successfully on both devices (TX_DONE fires, no errors). RSSI shows real signals (not noise floor). But neither device ever decodes the other's packets — no RX_DONE, no CRC errors, just silence. Each device works fine individually (e.g., over TCP/LXMF). + +**Diagnosis**: If two TCXO-equipped SX1262 devices can't hear each other despite confirmed TX and visible RSSI, suspect calibration ordering. + +**Fix**: In `SX1262::begin()`, the init order must be: +``` +enableTCXO() → delay(5ms) → setRegulatorMode(DC-DC) → loraMode() → standby() → calibrate() → calibrate_image() +``` + +Also set: +- **Regulator mode** to DC-DC (0x01) — default is LDO, wastes power on boards with DC-DC inductors +- **RX/TX fallback mode** to STDBY_XOSC (0x30) — default STDBY_RC (0x20) shuts off TCXO between TX/RX transitions + +### Debugging RF with RSSI Monitor + +Press **Ctrl+R** to sample RSSI continuously for 5 seconds. Transmit from another device during sampling. If RSSI stays at the noise floor (around -110 to -120 dBm), the RX front-end isn't hearing RF. + +### Radio test packet + +Press **Ctrl+T** to send a test packet with a fixed header (0xA0) and payload `RATPUTER_TEST_1234567890`. Includes FIFO readback verification. Use this to confirm the TX path is working without involving Reticulum. + +--- + +## WiFi Issues + +### AP and STA are separate modes + +Ratputer uses **three WiFi modes**: OFF, AP, STA. These are NOT concurrent — `WIFI_AP_STA` was removed because it consumed ~20KB extra heap and caused instability. + +- **AP mode**: Creates `ratputer-XXXX` hotspot, runs TCP server on port 4242 +- **STA mode**: Connects to an existing network, creates TCP client connections to configured endpoints +- **OFF**: No WiFi (saves power and heap) + +Switch modes in Settings → WiFi. + +### TCP client connection lifecycle + +In STA mode, TCP client connections to configured endpoints are created **once** on first WiFi connection. If WiFi drops and reconnects, existing TCP clients attempt reconnection automatically (every 10 seconds). + +--- + +## Known Limitations + +| Feature | Status | Notes | +|---------|--------|-------| +| BLE | Disabled (stub) | Saves ~50KB heap. Planned for v1.1 Sideband protocol | +| GNSS | Pins defined, no code | v1.1 — GPS RX=15, TX=13 | +| OTA updates | Partition exists, not implemented | `app1` partition at 0x310000 is reserved | +| LittleFS | Occasional mount failures | Auto-formats on first use; rare failures after that | +| WiFi AP+STA | Removed | Uses too much heap; separate AP/STA modes instead | +| Split packets | Header flag defined, not implemented | Single-frame LoRa only (max 254 bytes payload) | + +--- + +## Factory Reset + +### SD card wipe (preserves flash data) + +Connect serial at 115200 baud. Power cycle the device and send `WIPE` within 500ms of boot. This recursively deletes `/ratputer/*` on the SD card and recreates clean directories. + +### Flash erase (full reset) + +Erase all flash contents including LittleFS, NVS, and firmware: + +```bash +python3 -m esptool --chip esp32s3 --port /dev/cu.usbmodem* erase-flash +``` + +Then reflash the firmware. A new Reticulum identity will be generated on first boot. All settings, messages, and contacts stored in flash are lost. SD card data is preserved. + +### Settings-only reset + +In Settings → About → Factory Reset: clears the user config JSON from flash and SD, then reboots. Radio, WiFi, display, and audio revert to compile-time defaults. Identity and messages are preserved. + +--- + +## Diagnostic Reference + +### Serial log tags + +Every subsystem logs with a tag prefix for easy filtering: + +| Tag | Subsystem | +|-----|-----------| +| `[BOOT]` | Setup sequence | +| `[RADIO]` | SX1262 driver | +| `[LORA_IF]` | LoRa ↔ Reticulum interface | +| `[WIFI]` | WiFi AP/STA | +| `[TCP]` | TCP client connections | +| `[LXMF]` | LXMF message protocol | +| `[SD]` | SD card storage | +| `[HOTKEY]` | Keyboard hotkey dispatch | +| `[TEST]` | Radio test packet (Ctrl+T) | +| `[RSSI]` | RSSI monitor (Ctrl+R) | +| `[AUTO]` | Periodic auto-announce | +| `[BLE]` | BLE stub status | + +### Ctrl+D diagnostic fields + +| Field | Meaning | +|-------|---------| +| Identity | 16-byte Reticulum destination hash (hex) | +| Transport | ACTIVE or OFFLINE | +| Paths / Links | Number of known Reticulum paths and active links | +| Freq / SF / BW / CR / TXP | Current radio parameters | +| Preamble | Preamble length in symbols | +| SyncWord regs | Raw 0x0740/0x0741 register values (should be 0x14/0x24 for Reticulum) | +| DevErrors | SX1262 error register (0x0040 = PLL lock failure) | +| Status | SX1262 chip mode and command status | +| Current RSSI | Instantaneous RSSI in dBm (noise floor ~-110 to -120 dBm) | +| Free heap | Available RAM in bytes (typical: ~120–150 KB) | +| Flash | LittleFS used/total bytes | +| Uptime | Seconds since boot | diff --git a/lv_conf.h b/lv_conf.h new file mode 100644 index 0000000..4a22ecd --- /dev/null +++ b/lv_conf.h @@ -0,0 +1,64 @@ +#ifndef LV_CONF_H +#define LV_CONF_H + +#include + +// Color depth: 16-bit RGB565 +#define LV_COLOR_DEPTH 16 + +// Memory: use stdlib malloc (PSRAM-aware on ESP32-S3) +#define LV_MEM_CUSTOM 1 +#define LV_MEM_CUSTOM_INCLUDE +#define LV_MEM_CUSTOM_ALLOC malloc +#define LV_MEM_CUSTOM_FREE free +#define LV_MEM_CUSTOM_REALLOC realloc + +// Tick: custom (provided by main loop) +#define LV_TICK_CUSTOM 1 +#define LV_TICK_CUSTOM_INCLUDE "Arduino.h" +#define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) + +// Display +#define LV_HOR_RES_MAX 320 +#define LV_VER_RES_MAX 240 +#define LV_DPI_DEF 130 + +// Logging +#define LV_USE_LOG 0 + +// Fonts - built-in +#define LV_FONT_MONTSERRAT_8 1 +#define LV_FONT_MONTSERRAT_10 1 +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_UNSCII_8 1 +#define LV_FONT_DEFAULT &lv_font_montserrat_12 + +// Widgets +#define LV_USE_LABEL 1 +#define LV_USE_BTN 1 +#define LV_USE_BTNMATRIX 1 +#define LV_USE_TEXTAREA 1 +#define LV_USE_LIST 1 +#define LV_USE_BAR 1 +#define LV_USE_SLIDER 1 +#define LV_USE_SWITCH 1 +#define LV_USE_DROPDOWN 1 +#define LV_USE_ROLLER 1 +#define LV_USE_TABLE 1 +#define LV_USE_TABVIEW 1 +#define LV_USE_IMG 1 +#define LV_USE_LINE 1 +#define LV_USE_ARC 1 +#define LV_USE_SPINNER 1 +#define LV_USE_MSGBOX 1 +#define LV_USE_KEYBOARD 1 + +// Scroll +#define LV_USE_FLEX 1 +#define LV_USE_GRID 1 + +// OS +#define LV_USE_OS LV_OS_NONE + +#endif // LV_CONF_H diff --git a/partitions_16MB.csv b/partitions_16MB.csv new file mode 100644 index 0000000..803a2a8 --- /dev/null +++ b/partitions_16MB.csv @@ -0,0 +1,7 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x400000, +app1, app, ota_1, 0x410000, 0x400000, +littlefs, data, spiffs, 0x810000, 0x7E0000, +coredump, data, coredump,0xFF0000, 0x10000, diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..f7a5197 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,40 @@ +[env:ratdeck_915] +platform = espressif32@6.7.0 +board = esp32-s3-devkitc-1 +framework = arduino + +board_build.flash_size = 16MB +board_build.partitions = partitions_16MB.csv +board_upload.flash_size = 16MB +board_build.arduino.memory_type = qio_opi + +build_flags = + -std=gnu++17 + -fexceptions + -DRATDECK=1 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_USB_MODE=1 + -DRNS_USE_FS + -DRNS_PERSIST_PATHS + -DMSGPACK_USE_BOOST=OFF + -DBOARD_HAS_PSRAM + -mfix-esp32-psram-cache-issue + -DDISPLAY_WIDTH=320 + -DDISPLAY_HEIGHT=240 + +build_unflags = + -fno-exceptions + -std=gnu++11 + +lib_deps = + https://github.com/attermann/microReticulum.git + https://github.com/attermann/Crypto.git + bblanchon/ArduinoJson@^7.4.2 + lovyan03/LovyanGFX@^1.1.16 + h2zero/NimBLE-Arduino@^2.1 + +lib_archive = false + +monitor_speed = 115200 +upload_speed = 460800 +upload_flags = --no-stub diff --git a/src/audio/AudioNotify.cpp b/src/audio/AudioNotify.cpp new file mode 100644 index 0000000..fa52d10 --- /dev/null +++ b/src/audio/AudioNotify.cpp @@ -0,0 +1,207 @@ +// Audio output for T-Deck Plus via I2S speaker amplifier +#include "AudioNotify.h" +#include "config/BoardConfig.h" +#include +#include + +#define AUDIO_SAMPLE_RATE 16000 +#define I2S_PORT I2S_NUM_0 + +void AudioNotify::begin() { + i2s_config_t i2s_config = {}; + i2s_config.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); + i2s_config.sample_rate = AUDIO_SAMPLE_RATE; + i2s_config.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + i2s_config.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT; + i2s_config.communication_format = I2S_COMM_FORMAT_STAND_I2S; + i2s_config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1; + i2s_config.dma_buf_count = 4; + i2s_config.dma_buf_len = 256; + i2s_config.use_apll = false; + i2s_config.tx_desc_auto_clear = true; + + i2s_pin_config_t pin_config = {}; + pin_config.mck_io_num = I2S_MCLK; + pin_config.bck_io_num = I2S_BCK; + pin_config.ws_io_num = I2S_WS; + pin_config.data_out_num = I2S_DOUT; + pin_config.data_in_num = I2S_PIN_NO_CHANGE; + + esp_err_t err = i2s_driver_install(I2S_PORT, &i2s_config, 0, NULL); + if (err != ESP_OK) { + Serial.printf("[AUDIO] I2S install failed: %d\n", err); + return; + } + + err = i2s_set_pin(I2S_PORT, &pin_config); + if (err != ESP_OK) { + Serial.printf("[AUDIO] I2S pin config failed: %d\n", err); + i2s_driver_uninstall(I2S_PORT); + return; + } + + i2s_zero_dma_buffer(I2S_PORT); + _i2sReady = true; + Serial.println("[AUDIO] I2S initialized"); +} + +void AudioNotify::end() { + if (_i2sReady) { + i2s_driver_uninstall(I2S_PORT); + _i2sReady = false; + } +} + +void AudioNotify::writeTone(uint16_t freq, uint16_t durationMs) { + if (!_enabled || !_i2sReady) return; + + int numSamples = (AUDIO_SAMPLE_RATE * durationMs) / 1000; + int16_t* buf = (int16_t*)malloc(numSamples * sizeof(int16_t)); + if (!buf) return; + + float vol = (_volume / 100.0f) * 16000.0f; + int fadeN = AUDIO_SAMPLE_RATE / 100; // 10ms fade + + for (int i = 0; i < numSamples; i++) { + float t = (float)i / AUDIO_SAMPLE_RATE; + // Fundamental + 2nd/3rd harmonics for warmth + float s = sinf(2.0f * M_PI * freq * t) * 0.70f + + sinf(2.0f * M_PI * freq * 2.0f * t) * 0.20f + + sinf(2.0f * M_PI * freq * 3.0f * t) * 0.10f; + // Fade envelope + float env = 1.0f; + if (i < fadeN) env = (float)i / fadeN; + if (i > numSamples - fadeN) env = (float)(numSamples - i) / fadeN; + buf[i] = (int16_t)(s * env * vol); + } + + size_t written = 0; + i2s_write(I2S_PORT, buf, numSamples * sizeof(int16_t), &written, portMAX_DELAY); + free(buf); +} + +void AudioNotify::writeSilence(uint16_t durationMs) { + if (!_i2sReady) return; + int numSamples = (AUDIO_SAMPLE_RATE * durationMs) / 1000; + int16_t* buf = (int16_t*)calloc(numSamples, sizeof(int16_t)); + if (!buf) return; + size_t written = 0; + i2s_write(I2S_PORT, buf, numSamples * sizeof(int16_t), &written, portMAX_DELAY); + free(buf); +} + +void AudioNotify::playMessage() { + if (!_enabled) return; + writeTone(1000, 50); + writeSilence(50); + writeTone(1000, 50); + writeSilence(30); +} + +void AudioNotify::playAnnounce() { + if (!_enabled) return; + writeTone(800, 30); + writeSilence(20); +} + +void AudioNotify::playError() { + if (!_enabled) return; + for (int i = 0; i < 3; i++) { + writeTone(400, 100); + if (i < 2) writeSilence(50); + } + writeSilence(30); +} + +void AudioNotify::playBoot() { + if (!_enabled || !_i2sReady) return; + + // === RATDECK BOOT SEQUENCE === + // Sci-fi computer startup: sweep -> digital arpeggio -> confirmation + // Total ~550ms + + const int sr = AUDIO_SAMPLE_RATE; + const int totalMs = 560; + const int totalSamples = sr * totalMs / 1000; + + int16_t* buf = (int16_t*)ps_malloc(totalSamples * sizeof(int16_t)); + if (!buf) { + buf = (int16_t*)malloc(totalSamples * sizeof(int16_t)); + if (!buf) return; + } + memset(buf, 0, totalSamples * sizeof(int16_t)); + + float vol = (_volume / 100.0f) * 16000.0f; + int pos = 0; + + // Helper: add a tone with harmonics at current position + auto addTone = [&](float freq, int ms) { + int n = sr * ms / 1000; + int fadeN = sr * 8 / 1000; // 8ms fade + for (int i = 0; i < n && (pos + i) < totalSamples; i++) { + float t = (float)i / sr; + float s = sinf(2.0f * M_PI * freq * t) * 0.65f + + sinf(2.0f * M_PI * freq * 2.0f * t) * 0.22f + + sinf(2.0f * M_PI * freq * 3.0f * t) * 0.13f; + float env = 1.0f; + if (i < fadeN) env = (float)i / fadeN; + if (i > n - fadeN) env = (float)(n - i) / fadeN; + buf[pos + i] = (int16_t)(s * env * vol); + } + pos += n; + }; + + // Helper: frequency sweep with harmonics + auto addSweep = [&](float startF, float endF, int ms) { + int n = sr * ms / 1000; + int fadeN = sr * 8 / 1000; + float phase = 0; + for (int i = 0; i < n && (pos + i) < totalSamples; i++) { + float t = (float)i / n; // 0..1 progress + float freq = startF + (endF - startF) * t * t; // quadratic sweep (accelerating) + phase += 2.0f * M_PI * freq / sr; + float s = sinf(phase) * 0.65f + + sinf(phase * 2.0f) * 0.22f + + sinf(phase * 3.0f) * 0.08f; + float env = 1.0f; + if (i < fadeN) env = (float)i / fadeN; + if (i > n - fadeN) env = (float)(n - i) / fadeN; + buf[pos + i] = (int16_t)(s * env * vol); + } + pos += n; + }; + + auto addSilence = [&](int ms) { + pos += sr * ms / 1000; + }; + + // Phase 1: Rising power sweep 300->1200Hz (160ms) — "systems powering up" + addSweep(300, 1200, 160); + addSilence(25); + + // Phase 2: Three quick ascending staccato notes — E5, G#5, B5 + // (E major triad in 2nd inversion — bright, triumphant, slightly edgy) + addTone(659, 45); // E5 + addSilence(12); + addTone(831, 45); // G#5 + addSilence(12); + addTone(988, 45); // B5 + addSilence(25); + + // Phase 3: Descending glitch sweep 2400->1600Hz (60ms) — "digital handshake" + addSweep(2400, 1600, 60); + addSilence(20); + + // Phase 4: Final confirmation — E6 (1319Hz), 100ms with clean decay — "online" + addTone(1319, 100); + + // Write entire sequence at once for seamless playback + size_t written = 0; + i2s_write(I2S_PORT, buf, pos * sizeof(int16_t), &written, portMAX_DELAY); + + // Flush with silence + memset(buf, 0, 512 * sizeof(int16_t)); + i2s_write(I2S_PORT, buf, 512 * sizeof(int16_t), &written, portMAX_DELAY); + + free(buf); +} diff --git a/src/audio/AudioNotify.h b/src/audio/AudioNotify.h new file mode 100644 index 0000000..baf2677 --- /dev/null +++ b/src/audio/AudioNotify.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +class AudioNotify { +public: + void begin(); + void end(); + + // Notification sounds + void playMessage(); + void playAnnounce(); + void playError(); + void playBoot(); // Sci-fi boot sequence + + // Settings + void setEnabled(bool enabled) { _enabled = enabled; } + bool isEnabled() const { return _enabled; } + void setVolume(uint8_t vol) { _volume = vol; } + uint8_t volume() const { return _volume; } + +private: + void writeTone(uint16_t freq, uint16_t durationMs); + void writeSilence(uint16_t durationMs); + + bool _enabled = true; + bool _i2sReady = false; + uint8_t _volume = 80; // 0-100 +}; diff --git a/src/config/BoardConfig.h b/src/config/BoardConfig.h new file mode 100644 index 0000000..ea01cdc --- /dev/null +++ b/src/config/BoardConfig.h @@ -0,0 +1,85 @@ +#pragma once + +// ============================================================================= +// Ratdeck — LilyGo T-Deck Plus Pin Definitions +// ============================================================================= + +// --- Power Control --- +// CRITICAL: Must be set HIGH at boot to enable all peripherals +#define BOARD_POWER_PIN 10 + +// --- SX1262 LoRa Radio (shared SPI bus) --- +#define LORA_CS 9 +#define LORA_IRQ 45 // DIO1 +#define LORA_RST 17 +#define LORA_BUSY 13 +#define LORA_RXEN -1 // Not connected +#define LORA_TXEN -1 // Not connected + +// --- SX1262 Radio Configuration --- +#define LORA_HAS_TCXO true +#define LORA_DIO2_AS_RF_SWITCH true +// TCXO voltage: 1.8V for T-Deck Plus integrated SX1262 (Ratputer Cap LoRa uses 3.0V/0x06) +#define LORA_TCXO_VOLTAGE 0x02 // MODE_TCXO_1_8V_6X +#define LORA_DEFAULT_FREQ 915000000 +#define LORA_DEFAULT_BW 125000 +#define LORA_DEFAULT_SF 9 +#define LORA_DEFAULT_CR 5 +#define LORA_DEFAULT_TX_POWER 22 +#define LORA_DEFAULT_PREAMBLE 18 + +// --- Shared SPI Bus (display + LoRa + SD) --- +#define SPI_SCK 40 +#define SPI_MISO 38 +#define SPI_MOSI 41 + +// --- Display (ST7789V via LovyanGFX) --- +#define TFT_CS 12 +#define TFT_DC 11 +#define TFT_BL 42 // Backlight PWM +#define TFT_WIDTH 320 +#define TFT_HEIGHT 240 +#define TFT_SPI_FREQ 15000000 // 15MHz (30MHz overclockable) + +// --- Keyboard (ESP32-C3 over I2C) --- +#define KB_I2C_ADDR 0x55 +#define KB_INT 46 // Interrupt pin + +// --- I2C Bus (shared: keyboard + touchscreen) --- +#define I2C_SDA 18 +#define I2C_SCL 8 + +// --- Touchscreen (GT911 capacitive) --- +#define TOUCH_INT 16 +// GT911 I2C address: typically 0x5D or 0x14 (depends on INT state at boot) +#define TOUCH_I2C_ADDR 0x5D + +// --- Trackball --- +#define TBALL_UP 3 +#define TBALL_DOWN 2 +#define TBALL_LEFT 1 +#define TBALL_RIGHT 15 +#define TBALL_CLICK 0 // Shared with BOOT button + +// --- SD Card (shared SPI bus) --- +#define SD_CS 39 + +// --- GPS (UBlox MIA-M10Q UART) --- +#define GPS_TX 43 // ESP TX -> GPS RX +#define GPS_RX 44 // GPS TX -> ESP RX +#define GPS_BAUD 115200 + +// --- Battery ADC --- +#define BAT_ADC_PIN 4 + +// --- Audio (ES7210 I2S) --- +#define I2S_WS 5 // LRCK +#define I2S_DOUT 6 +#define I2S_BCK 7 +#define I2S_DIN 14 +#define I2S_SCK 47 +#define I2S_MCLK 48 + +// --- Hardware Constants --- +#define MAX_PACKET_SIZE 255 +#define SPI_FREQUENCY 8000000 // 8 MHz SPI clock for SX1262 diff --git a/src/config/Config.h b/src/config/Config.h new file mode 100644 index 0000000..0bf3134 --- /dev/null +++ b/src/config/Config.h @@ -0,0 +1,62 @@ +#pragma once + +// ============================================================================= +// Ratdeck — Compile-Time Configuration +// ============================================================================= + +#define RATDECK_VERSION_MAJOR 1 +#define RATDECK_VERSION_MINOR 0 +#define RATDECK_VERSION_PATCH 0 +#define RATDECK_VERSION_STRING "1.0.0" + +// --- Feature Flags --- +#define HAS_DISPLAY true +#define HAS_KEYBOARD true +#define HAS_TOUCH true +#define HAS_TRACKBALL true +#define HAS_LORA true +#define HAS_WIFI true +#define HAS_BLE true +#define HAS_SD true +#define HAS_AUDIO true +#define HAS_GPS false // Deprioritized + +// --- WiFi Defaults --- +#define WIFI_AP_PORT 4242 +#define WIFI_AP_PASSWORD "ratspeak" + +// --- Storage Paths --- +#define PATH_IDENTITY "/identity/identity.key" +#define PATH_IDENTITY_BAK "/identity/identity.key.bak" +#define PATH_PATHS "/transport/paths.msgpack" +#define PATH_USER_CONFIG "/config/user.json" +#define PATH_CONTACTS "/contacts/" +#define PATH_MESSAGES "/messages/" + +// --- SD Card Paths (shared with Ratputer) --- +#define SD_PATH_CONFIG_DIR "/ratputer/config" +#define SD_PATH_USER_CONFIG "/ratputer/config/user.json" +#define SD_PATH_MESSAGES "/ratputer/messages/" +#define SD_PATH_CONTACTS "/ratputer/contacts/" +#define SD_PATH_IDENTITY "/ratputer/identity/identity.key" + +// --- TCP Client --- +#define MAX_TCP_CONNECTIONS 4 +#define TCP_DEFAULT_PORT 4242 +#define TCP_RECONNECT_INTERVAL_MS 10000 +#define TCP_CONNECT_TIMEOUT_MS 5000 + +// --- Limits --- +#define RATDECK_MAX_NODES 200 // PSRAM allows more +#define RATDECK_MAX_MESSAGES_PER_CONV 100 +#define FLASH_MSG_CACHE_LIMIT 20 +#define RATDECK_MAX_OUTQUEUE 20 +#define PATH_PERSIST_INTERVAL_MS 60000 + +// --- Power Management --- +#define SCREEN_DIM_TIMEOUT_MS 30000 +#define SCREEN_OFF_TIMEOUT_MS 60000 +#define SCREEN_DIM_BRIGHTNESS 64 + +// --- Serial Debug --- +#define SERIAL_BAUD 115200 diff --git a/src/config/UserConfig.cpp b/src/config/UserConfig.cpp new file mode 100644 index 0000000..81f18d1 --- /dev/null +++ b/src/config/UserConfig.cpp @@ -0,0 +1,176 @@ +#include "UserConfig.h" +#include "config/BoardConfig.h" + +bool UserConfig::parseJson(const String& json) { + Serial.printf("[CONFIG] Raw JSON (%d bytes): %s\n", json.length(), json.c_str()); + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, json); + if (err) { + Serial.printf("[CONFIG] Parse error: %s\n", err.c_str()); + return false; + } + + _settings.loraFrequency = doc["lora_freq"] | (long)LORA_DEFAULT_FREQ; + _settings.loraSF = doc["lora_sf"] | (int)LORA_DEFAULT_SF; + _settings.loraBW = doc["lora_bw"] | (long)LORA_DEFAULT_BW; + _settings.loraCR = doc["lora_cr"] | (int)LORA_DEFAULT_CR; + _settings.loraTxPower = doc["lora_txp"] | (int)LORA_DEFAULT_TX_POWER; + + // WiFi mode — migrate from legacy wifi_enabled bool + int mode = doc["wifi_mode"] | -1; + if (mode >= 0) { + _settings.wifiMode = (RatWiFiMode)constrain(mode, 0, 2); + } else { + _settings.wifiMode = (doc["wifi_enabled"] | true) ? RAT_WIFI_AP : RAT_WIFI_OFF; + } + _settings.wifiAPSSID = doc["wifi_ap_ssid"] | ""; + _settings.wifiAPPassword = doc["wifi_ap_pass"] | WIFI_AP_PASSWORD; + _settings.wifiSTASSID = doc["wifi_sta_ssid"] | ""; + _settings.wifiSTAPassword = doc["wifi_sta_pass"] | ""; + + // TCP outbound connections + _settings.tcpConnections.clear(); + JsonArray tcpArr = doc["tcp_connections"]; + if (tcpArr) { + for (JsonObject obj : tcpArr) { + if (_settings.tcpConnections.size() >= MAX_TCP_CONNECTIONS) break; + TCPEndpoint ep; + ep.host = obj["host"] | ""; + ep.port = obj["port"] | TCP_DEFAULT_PORT; + ep.autoConnect = obj["auto"] | true; + if (!ep.host.isEmpty()) _settings.tcpConnections.push_back(ep); + } + } + + _settings.screenDimTimeout = doc["screen_dim"] | 30; + _settings.screenOffTimeout = doc["screen_off"] | 60; + _settings.brightness = doc["brightness"] | 255; + _settings.denseFontMode = doc["dense_font"] | false; + _settings.trackballSpeed = doc["trackball_speed"] | 3; + _settings.touchSensitivity = doc["touch_sens"] | 3; + _settings.bleEnabled = doc["ble_enabled"] | true; + + _settings.audioEnabled = doc["audio_on"] | true; + _settings.audioVolume = doc["audio_vol"] | 80; + + _settings.displayName = doc["display_name"] | ""; + + Serial.println("[CONFIG] Settings loaded"); + return true; +} + +String UserConfig::serializeToJson() const { + JsonDocument doc; + + doc["lora_freq"] = _settings.loraFrequency; + doc["lora_sf"] = _settings.loraSF; + doc["lora_bw"] = _settings.loraBW; + doc["lora_cr"] = _settings.loraCR; + doc["lora_txp"] = _settings.loraTxPower; + + doc["wifi_mode"] = (int)_settings.wifiMode; + doc["wifi_ap_ssid"] = _settings.wifiAPSSID; + doc["wifi_ap_pass"] = _settings.wifiAPPassword; + doc["wifi_sta_ssid"] = _settings.wifiSTASSID; + doc["wifi_sta_pass"] = _settings.wifiSTAPassword; + + JsonArray tcpArr = doc["tcp_connections"].to(); + for (auto& ep : _settings.tcpConnections) { + JsonObject obj = tcpArr.add(); + obj["host"] = ep.host; + obj["port"] = ep.port; + obj["auto"] = ep.autoConnect; + } + + doc["screen_dim"] = _settings.screenDimTimeout; + doc["screen_off"] = _settings.screenOffTimeout; + doc["brightness"] = _settings.brightness; + doc["dense_font"] = _settings.denseFontMode; + doc["trackball_speed"] = _settings.trackballSpeed; + doc["touch_sens"] = _settings.touchSensitivity; + doc["ble_enabled"] = _settings.bleEnabled; + + doc["audio_on"] = _settings.audioEnabled; + doc["audio_vol"] = _settings.audioVolume; + + doc["display_name"] = _settings.displayName; + + String json; + serializeJson(doc, json); + return json; +} + +bool UserConfig::load(FlashStore& flash) { + String json = flash.readString(PATH_USER_CONFIG); + if (json.isEmpty()) { + Serial.println("[CONFIG] No saved config, using defaults"); + return false; + } + return parseJson(json); +} + +bool UserConfig::save(FlashStore& flash) { + String json = serializeToJson(); + bool ok = flash.writeString(PATH_USER_CONFIG, json); + if (ok) Serial.println("[CONFIG] Settings saved to flash"); + return ok; +} + +bool UserConfig::load(SDStore& sd, FlashStore& flash) { + // Try SD card first + if (sd.isReady()) { + String json = sd.readString(SD_PATH_USER_CONFIG); + if (!json.isEmpty()) { + Serial.println("[CONFIG] Loading from SD card"); + return parseJson(json); + } + } + + // Fall back to flash + String json = flash.readString(PATH_USER_CONFIG); + if (json.isEmpty()) { + Serial.println("[CONFIG] No saved config, using defaults"); + return false; + } + + bool ok = parseJson(json); + + // Auto-migrate: flash had config but SD didn't — copy to SD + if (ok && sd.isReady()) { + Serial.println("[CONFIG] Migrating config from flash to SD..."); + sd.ensureDir("/ratputer"); + sd.ensureDir("/ratputer/config"); + String migrateJson = serializeToJson(); + if (sd.writeString(SD_PATH_USER_CONFIG, migrateJson)) { + Serial.println("[CONFIG] Migration complete"); + } + } + + return ok; +} + +bool UserConfig::save(SDStore& sd, FlashStore& flash) { + String json = serializeToJson(); + bool ok = false; + + // Write to SD (primary) + if (sd.isReady()) { + sd.ensureDir("/ratputer"); + sd.ensureDir("/ratputer/config"); + if (sd.writeString(SD_PATH_USER_CONFIG, json)) { + Serial.println("[CONFIG] Saved to SD"); + ok = true; + } else { + Serial.println("[CONFIG] SD write failed"); + } + } + + // Write to flash (backup) + if (flash.writeString(PATH_USER_CONFIG, json)) { + Serial.println("[CONFIG] Saved to flash"); + ok = true; + } + + return ok; +} diff --git a/src/config/UserConfig.h b/src/config/UserConfig.h new file mode 100644 index 0000000..695ab4a --- /dev/null +++ b/src/config/UserConfig.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include "storage/FlashStore.h" +#include "storage/SDStore.h" +#include "config/Config.h" +#include "config/BoardConfig.h" + +enum RatWiFiMode : uint8_t { RAT_WIFI_OFF = 0, RAT_WIFI_AP = 1, RAT_WIFI_STA = 2 }; + +struct TCPEndpoint { + String host; + uint16_t port = TCP_DEFAULT_PORT; + bool autoConnect = true; +}; + +struct UserSettings { + // Radio + uint32_t loraFrequency = LORA_DEFAULT_FREQ; + uint8_t loraSF = LORA_DEFAULT_SF; + uint32_t loraBW = LORA_DEFAULT_BW; + uint8_t loraCR = LORA_DEFAULT_CR; + int8_t loraTxPower = LORA_DEFAULT_TX_POWER; + + // WiFi + RatWiFiMode wifiMode = RAT_WIFI_AP; + String wifiAPSSID; + String wifiAPPassword = WIFI_AP_PASSWORD; + String wifiSTASSID; + String wifiSTAPassword; + + // TCP outbound connections (STA mode only) + std::vector tcpConnections; + + // Display + uint16_t screenDimTimeout = 30; // seconds + uint16_t screenOffTimeout = 60; // seconds + uint8_t brightness = 255; + bool denseFontMode = false; // T-Deck Plus: adaptive font toggle + + // Trackball + uint8_t trackballSpeed = 3; // 1-5 sensitivity + + // Touch + uint8_t touchSensitivity = 3; // 1-5 + + // BLE + bool bleEnabled = true; + + // Audio + bool audioEnabled = true; + uint8_t audioVolume = 80; // 0-100 + + // Identity + String displayName; +}; + +class UserConfig { +public: + // Flash-only (original API, kept for compatibility) + bool load(FlashStore& flash); + bool save(FlashStore& flash); + + // Dual-backend: SD primary, flash fallback + bool load(SDStore& sd, FlashStore& flash); + bool save(SDStore& sd, FlashStore& flash); + + UserSettings& settings() { return _settings; } + const UserSettings& settings() const { return _settings; } + +private: + bool parseJson(const String& json); + String serializeToJson() const; + + UserSettings _settings; +}; diff --git a/src/hal/Audio.cpp b/src/hal/Audio.cpp new file mode 100644 index 0000000..d577e47 --- /dev/null +++ b/src/hal/Audio.cpp @@ -0,0 +1,13 @@ +#include "Audio.h" +#include "config/BoardConfig.h" + +bool Audio::begin() { + // TODO: Initialize ES7210 codec via I2S + // Pins: I2S_WS=5, I2S_DOUT=6, I2S_BCK=7, I2S_DIN=14, I2S_SCK=47, I2S_MCLK=48 + Serial.println("[AUDIO] ES7210 init (stub)"); + return true; +} + +void Audio::setVolume(uint8_t vol) { + _volume = vol; +} diff --git a/src/hal/Audio.h b/src/hal/Audio.h new file mode 100644 index 0000000..c7d0fc9 --- /dev/null +++ b/src/hal/Audio.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +// ES7210 I2S audio codec driver for T-Deck Plus +// Stub — full I2S implementation in Phase 6 +class Audio { +public: + bool begin(); + void setVolume(uint8_t vol); + uint8_t volume() const { return _volume; } + +private: + uint8_t _volume = 80; +}; diff --git a/src/hal/Display.cpp b/src/hal/Display.cpp new file mode 100644 index 0000000..c5c2890 --- /dev/null +++ b/src/hal/Display.cpp @@ -0,0 +1,36 @@ +#include "Display.h" + +bool Display::begin() { + _gfx.init(); + _gfx.setRotation(1); // Landscape: 320x240 + _gfx.setBrightness(128); + _gfx.fillScreen(TFT_BLACK); + + Serial.printf("[DISPLAY] Initialized: %dx%d (rotation=1, LovyanGFX direct)\n", + _gfx.width(), _gfx.height()); + + // Quick visual test: draw colored rectangles directly + _gfx.fillRect(0, 0, 107, 120, TFT_RED); + _gfx.fillRect(107, 0, 107, 120, TFT_GREEN); + _gfx.fillRect(214, 0, 106, 120, TFT_BLUE); + _gfx.setTextColor(TFT_WHITE, TFT_BLACK); + _gfx.setTextSize(2); + _gfx.setCursor(60, 140); + _gfx.print("RATDECK DISPLAY TEST"); + delay(1500); + _gfx.fillScreen(TFT_BLACK); + + return true; +} + +void Display::setBrightness(uint8_t level) { + _gfx.setBrightness(level); +} + +void Display::sleep() { + _gfx.sleep(); +} + +void Display::wakeup() { + _gfx.wakeup(); +} diff --git a/src/hal/Display.h b/src/hal/Display.h new file mode 100644 index 0000000..489f48f --- /dev/null +++ b/src/hal/Display.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include "config/BoardConfig.h" + +// LovyanGFX display configuration for T-Deck Plus +// Hardware panel is ST7789V (240x320) +class LGFX_TDeck : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel; + lgfx::Bus_SPI _bus; + lgfx::Light_PWM _light; + +public: + LGFX_TDeck() { + // SPI bus config + auto cfg_bus = _bus.config(); + cfg_bus.spi_host = SPI2_HOST; + cfg_bus.spi_mode = 0; + cfg_bus.freq_write = 27000000; // 27MHz — reliable on shared SPI bus + cfg_bus.freq_read = 16000000; + cfg_bus.pin_sclk = SPI_SCK; + cfg_bus.pin_miso = SPI_MISO; + cfg_bus.pin_mosi = SPI_MOSI; + cfg_bus.pin_dc = TFT_DC; + _bus.config(cfg_bus); + _panel.setBus(&_bus); + + // Panel config — native orientation is 240x320 portrait + auto cfg_panel = _panel.config(); + cfg_panel.pin_cs = TFT_CS; + cfg_panel.pin_rst = -1; + cfg_panel.panel_width = 240; + cfg_panel.panel_height = 320; + cfg_panel.offset_x = 0; + cfg_panel.offset_y = 0; + cfg_panel.invert = true; + cfg_panel.rgb_order = false; + cfg_panel.memory_width = 240; + cfg_panel.memory_height = 320; + _panel.config(cfg_panel); + + // Backlight config + auto cfg_light = _light.config(); + cfg_light.pin_bl = TFT_BL; + cfg_light.invert = false; + cfg_light.freq = 12000; + cfg_light.pwm_channel = 0; + _light.config(cfg_light); + _panel.setLight(&_light); + + setPanel(&_panel); + } +}; + +class Display { +public: + bool begin(); + + // Backlight + void setBrightness(uint8_t level); + void sleep(); + void wakeup(); + + LGFX_TDeck& gfx() { return _gfx; } + +private: + LGFX_TDeck _gfx; +}; diff --git a/src/hal/GPS.cpp b/src/hal/GPS.cpp new file mode 100644 index 0000000..12c0558 --- /dev/null +++ b/src/hal/GPS.cpp @@ -0,0 +1,13 @@ +#include "GPS.h" +#include "config/BoardConfig.h" + +bool GPS::begin() { + // GPS is deprioritized — stub only + // UBlox MIA-M10Q on UART: TX=43, RX=44, 115200 baud + Serial.println("[GPS] Disabled (deprioritized)"); + return false; +} + +void GPS::loop() { + // No-op +} diff --git a/src/hal/GPS.h b/src/hal/GPS.h new file mode 100644 index 0000000..cc8ad49 --- /dev/null +++ b/src/hal/GPS.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +// UBlox MIA-M10Q GPS driver for T-Deck Plus +// Stub — GPS is deprioritized per plan +class GPS { +public: + bool begin(); + void loop(); + + bool hasFix() const { return false; } + double latitude() const { return 0; } + double longitude() const { return 0; } + int satellites() const { return 0; } + +private: + bool _enabled = false; +}; diff --git a/src/hal/Keyboard.cpp b/src/hal/Keyboard.cpp new file mode 100644 index 0000000..d2b7457 --- /dev/null +++ b/src/hal/Keyboard.cpp @@ -0,0 +1,100 @@ +#include "Keyboard.h" + +Keyboard* Keyboard::_instance = nullptr; +int Keyboard::_debugCount = 0; + +bool Keyboard::begin() { + _instance = this; + _mode = InputMode::Navigation; + _hasEvent = false; + + // KB interrupt pin + pinMode(KB_INT, INPUT_PULLUP); + + // Verify I2C communication with keyboard controller + Wire.beginTransmission(KB_I2C_ADDR); + uint8_t err = Wire.endTransmission(); + if (err != 0) { + Serial.printf("[KEYBOARD] ESP32-C3 not found at 0x%02X (err=%d)\n", KB_I2C_ADDR, err); + return false; + } + + Serial.println("[KEYBOARD] ESP32-C3 keyboard ready"); + Serial.println("[KEYBOARD] Alt+I/M/J/L = Up/Down/Left/Right, Backspace = Back"); + return true; +} + +uint8_t Keyboard::readKey(uint8_t* modOut) { + *modOut = 0; + // Read 2 bytes: byte 1 = key, byte 2 = potential modifier flags + Wire.requestFrom((uint8_t)KB_I2C_ADDR, (uint8_t)2); + uint8_t key = 0; + if (Wire.available()) key = Wire.read(); + if (Wire.available()) *modOut = Wire.read(); + return key; +} + +void Keyboard::update() { + _hasEvent = false; + + uint8_t mod = 0; + uint8_t key = readKey(&mod); + if (key == 0 || key == _lastKey) { + if (key == 0) _lastKey = 0; + return; + } + _lastKey = key; + + // Debug logging for first 50 keypresses to help diagnose key mapping + if (_debugCount < 50) { + _debugCount++; + Serial.printf("[KB] raw: key=0x%02X ('%c') mod=0x%02X\n", + key, (key >= 0x20 && key < 0x7F) ? (char)key : '?', mod); + } + + _event = {}; + + // Check for Alt in modifier byte (try common bit positions) + // BBQ-style keyboards: bit 1=Alt, bit 2=Sym, bit 0=Ctrl + // Also try bit 3, bit 4 as some firmwares use those + bool altFromMod = (mod & 0x02) || (mod & 0x08); + + // Track Alt state: if the keyboard sends Alt as a standalone keypress, + // it might come as a specific byte. Common values: + // 0x1B = Esc (unlikely to be Alt), but some controllers use high bytes + // The T-Deck Alt key might send no byte at all when pressed alone. + + if (altFromMod) { + _event.alt = true; + // Map Alt+IJKL/M to arrow keys + char lower = tolower(key); + if (lower == 'i') { _event.up = true; _hasEvent = true; return; } + if (lower == 'm') { _event.down = true; _hasEvent = true; return; } + if (lower == 'j') { _event.left = true; _hasEvent = true; return; } + if (lower == 'l') { _event.right = true; _hasEvent = true; return; } + } + + // Standard key decoding + if (key == 0x0D || key == '\n') { + _event.enter = true; + _event.character = '\n'; + } else if (key == 0x08 || key == 0x7F) { + _event.del = true; + _event.character = 0x08; + } else if (key == 0x09) { + _event.tab = true; + } else if (key == 0x1B) { + _event.character = 27; // ESC + } else if (key == ' ') { + _event.space = true; + _event.character = ' '; + } else if (key >= 0x01 && key <= 0x1A) { + // Ctrl+A through Ctrl+Z + _event.ctrl = true; + _event.character = key + 'a' - 1; + } else if (key >= 0x20 && key <= 0x7E) { + _event.character = key; + } + + _hasEvent = true; +} diff --git a/src/hal/Keyboard.h b/src/hal/Keyboard.h new file mode 100644 index 0000000..b4eba1e --- /dev/null +++ b/src/hal/Keyboard.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include "config/BoardConfig.h" + +// Input modes +enum class InputMode { + Navigation, // Arrow-like movement, hotkeys active + TextInput // Character entry, Esc exits to Navigation +}; + +// Simplified key event for consumers +struct KeyEvent { + char character; + bool ctrl; + bool shift; + bool fn; + bool alt; + bool opt; + bool enter; + bool del; + bool tab; + bool space; + // Directional arrows (from Alt+IJKL/M or trackball) + bool up; + bool down; + bool left; + bool right; +}; + +class Keyboard { +public: + bool begin(); + void update(); + + // Mode control + InputMode getMode() const { return _mode; } + void setMode(InputMode mode) { _mode = mode; } + + // State queries + bool hasEvent() const { return _hasEvent; } + const KeyEvent& getEvent() const { return _event; } + +private: + uint8_t readKey(uint8_t* modOut); + + InputMode _mode = InputMode::Navigation; + KeyEvent _event = {}; + bool _hasEvent = false; + uint8_t _lastKey = 0; + bool _altHeld = false; // Software Alt tracking + unsigned long _altPressTime = 0; // When Alt was detected + + static Keyboard* _instance; + static int _debugCount; // Log first N keypresses +}; diff --git a/src/hal/Power.cpp b/src/hal/Power.cpp new file mode 100644 index 0000000..4114825 --- /dev/null +++ b/src/hal/Power.cpp @@ -0,0 +1,94 @@ +#include "Power.h" +#include "hal/Display.h" + +// Forward declaration — display instance provided externally +extern Display display; + +void Power::enablePeripherals() { + // CRITICAL: GPIO 10 must be HIGH to enable all T-Deck Plus peripherals + pinMode(BOARD_POWER_PIN, OUTPUT); + digitalWrite(BOARD_POWER_PIN, HIGH); + delay(10); // Allow peripherals to stabilize +} + +void Power::begin() { + _lastActivity = millis(); + _state = ACTIVE; + + // Configure battery ADC + pinMode(BAT_ADC_PIN, INPUT); + analogReadResolution(12); + + Serial.println("[POWER] Power manager initialized"); +} + +float Power::batteryVoltage() const { + // T-Deck Plus: voltage divider on GPIO 4 + int raw = analogRead(BAT_ADC_PIN); + // Voltage divider: 2x ratio, 3.3V reference, 12-bit ADC + return (raw / 4095.0f) * 3.3f * 2.0f; +} + +int Power::batteryPercent() const { + float v = batteryVoltage(); + // LiPo voltage curve approximation + if (v >= 4.2f) return 100; + if (v <= 3.0f) return 0; + return (int)((v - 3.0f) / 1.2f * 100.0f); +} + +void Power::activity() { + _lastActivity = millis(); + if (_state != ACTIVE) { + setState(ACTIVE); + } +} + +void Power::setBrightness(uint8_t brightness) { + _fullBrightness = brightness; + if (_state == ACTIVE) { + display.setBrightness(_fullBrightness); + } +} + +void Power::loop() { + unsigned long elapsed = millis() - _lastActivity; + + switch (_state) { + case ACTIVE: + if (_offTimeout > 0 && elapsed >= _offTimeout) { + setState(SCREEN_OFF); + } else if (_dimTimeout > 0 && elapsed >= _dimTimeout) { + setState(DIMMED); + } + break; + + case DIMMED: + if (_offTimeout > 0 && elapsed >= _offTimeout) { + setState(SCREEN_OFF); + } + break; + + case SCREEN_OFF: + break; + } +} + +void Power::setState(State newState) { + if (newState == _state) return; + _state = newState; + + switch (_state) { + case ACTIVE: + display.wakeup(); + display.setBrightness(_fullBrightness); + break; + case DIMMED: + display.setBrightness(DIM_BRIGHTNESS); + break; + case SCREEN_OFF: + display.setBrightness(0); + display.sleep(); + break; + } +} diff --git a/src/hal/Power.h b/src/hal/Power.h new file mode 100644 index 0000000..00a3641 --- /dev/null +++ b/src/hal/Power.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include "config/BoardConfig.h" + +class Power { +public: + // Enable peripheral power (GPIO 10 HIGH) — call first in setup + static void enablePeripherals(); + + void begin(); + void loop(); + + // Call on any user activity (keypress, touch, trackball) + void activity(); + + // Battery + float batteryVoltage() const; + int batteryPercent() const; + + // Backlight + void setBrightness(uint8_t brightness); + void setDimTimeout(uint16_t seconds) { _dimTimeout = seconds * 1000UL; } + void setOffTimeout(uint16_t seconds) { _offTimeout = seconds * 1000UL; } + + enum State { ACTIVE, DIMMED, SCREEN_OFF }; + State state() const { return _state; } + bool isScreenOn() const { return _state != SCREEN_OFF; } + +private: + void setState(State newState); + + State _state = ACTIVE; + unsigned long _lastActivity = 0; + unsigned long _dimTimeout = 30000; + unsigned long _offTimeout = 60000; + uint8_t _fullBrightness = 255; + static constexpr uint8_t DIM_BRIGHTNESS = 64; +}; diff --git a/src/hal/TouchInput.cpp b/src/hal/TouchInput.cpp new file mode 100644 index 0000000..3c7d372 --- /dev/null +++ b/src/hal/TouchInput.cpp @@ -0,0 +1,94 @@ +#include "TouchInput.h" + +TouchInput* TouchInput::_instance = nullptr; + +bool TouchInput::begin() { + _instance = this; + + // GT911 INT pin + pinMode(TOUCH_INT, INPUT); + + // Try GT911 at both possible addresses + Wire.beginTransmission(TOUCH_I2C_ADDR); + uint8_t err = Wire.endTransmission(); + if (err != 0) { + Wire.beginTransmission(0x14); + err = Wire.endTransmission(); + if (err != 0) { + Serial.println("[TOUCH] GT911 not found at 0x5D or 0x14"); + return false; + } + Serial.println("[TOUCH] GT911 found at 0x14"); + } else { + Serial.println("[TOUCH] GT911 found at 0x5D"); + } + + Serial.println("[TOUCH] Touch input registered"); + return true; +} + +void TouchInput::update() { + readGT911(); +} + +bool TouchInput::readGT911() { + // Read touch status register (0x814E) + Wire.beginTransmission(TOUCH_I2C_ADDR); + Wire.write(0x81); + Wire.write(0x4E); + if (Wire.endTransmission() != 0) { + _touched = false; + return false; + } + + Wire.requestFrom((uint8_t)TOUCH_I2C_ADDR, (uint8_t)1); + if (!Wire.available()) { + _touched = false; + return false; + } + + uint8_t status = Wire.read(); + uint8_t touchCount = status & 0x0F; + bool bufferReady = (status & 0x80) != 0; + + if (!bufferReady || touchCount == 0) { + _touched = false; + Wire.beginTransmission(TOUCH_I2C_ADDR); + Wire.write(0x81); + Wire.write(0x4E); + Wire.write(0x00); + Wire.endTransmission(); + return false; + } + + // Read first touch point (0x8150-0x8157) + Wire.beginTransmission(TOUCH_I2C_ADDR); + Wire.write(0x81); + Wire.write(0x50); + if (Wire.endTransmission() != 0) { + _touched = false; + return false; + } + + Wire.requestFrom((uint8_t)TOUCH_I2C_ADDR, (uint8_t)6); + if (Wire.available() < 6) { + _touched = false; + return false; + } + + Wire.read(); // track ID + _x = Wire.read() | (Wire.read() << 8); + _y = Wire.read() | (Wire.read() << 8); + Wire.read(); // size (unused) + + _touched = true; + + // Clear buffer status + Wire.beginTransmission(TOUCH_I2C_ADDR); + Wire.write(0x81); + Wire.write(0x4E); + Wire.write(0x00); + Wire.endTransmission(); + + return true; +} diff --git a/src/hal/TouchInput.h b/src/hal/TouchInput.h new file mode 100644 index 0000000..dce311b --- /dev/null +++ b/src/hal/TouchInput.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include "config/BoardConfig.h" + +class TouchInput { +public: + bool begin(); + + // Raw touch state + bool isTouched() const { return _touched; } + int16_t x() const { return _x; } + int16_t y() const { return _y; } + + void update(); + +private: + bool readGT911(); + + bool _touched = false; + int16_t _x = 0; + int16_t _y = 0; + + static TouchInput* _instance; +}; diff --git a/src/hal/Trackball.cpp b/src/hal/Trackball.cpp new file mode 100644 index 0000000..266a82b --- /dev/null +++ b/src/hal/Trackball.cpp @@ -0,0 +1,58 @@ +#include "Trackball.h" + +volatile int8_t Trackball::_deltaX = 0; +volatile int8_t Trackball::_deltaY = 0; +volatile bool Trackball::_clickFlag = false; +Trackball* Trackball::_instance = nullptr; + +bool Trackball::begin() { + _instance = this; + + // Configure trackball GPIOs as inputs with pullup + pinMode(TBALL_UP, INPUT_PULLUP); + pinMode(TBALL_DOWN, INPUT_PULLUP); + pinMode(TBALL_LEFT, INPUT_PULLUP); + pinMode(TBALL_RIGHT, INPUT_PULLUP); + pinMode(TBALL_CLICK, INPUT_PULLUP); + + // Attach interrupts for movement detection + attachInterrupt(digitalPinToInterrupt(TBALL_UP), isrUp, FALLING); + attachInterrupt(digitalPinToInterrupt(TBALL_DOWN), isrRight, FALLING); // Physical down pin = rightward + attachInterrupt(digitalPinToInterrupt(TBALL_LEFT), isrLeft, FALLING); + attachInterrupt(digitalPinToInterrupt(TBALL_RIGHT), isrDown, FALLING); // Physical right pin = downward + attachInterrupt(digitalPinToInterrupt(TBALL_CLICK), isrClick, FALLING); + + Serial.println("[TRACKBALL] Initialized"); + return true; +} + +void Trackball::update() { + noInterrupts(); + int8_t dx = _deltaX; + int8_t dy = _deltaY; + bool click = _clickFlag; + _deltaX = 0; + _deltaY = 0; + _clickFlag = false; + interrupts(); + + _lastDX = dx; + _lastDY = dy; + + _cursorX += dx * _speed; + _cursorY += dy * _speed; + + if (_cursorX < 0) _cursorX = 0; + if (_cursorX >= TFT_WIDTH) _cursorX = TFT_WIDTH - 1; + if (_cursorY < 0) _cursorY = 0; + if (_cursorY >= TFT_HEIGHT) _cursorY = TFT_HEIGHT - 1; + + _clicked = click; + _hadMovement = (dx != 0 || dy != 0); +} + +void IRAM_ATTR Trackball::isrUp() { _deltaY--; } +void IRAM_ATTR Trackball::isrDown() { _deltaY++; } +void IRAM_ATTR Trackball::isrLeft() { _deltaX--; } +void IRAM_ATTR Trackball::isrRight() { _deltaX++; } +void IRAM_ATTR Trackball::isrClick() { _clickFlag = true; } diff --git a/src/hal/Trackball.h b/src/hal/Trackball.h new file mode 100644 index 0000000..2e05f24 --- /dev/null +++ b/src/hal/Trackball.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include "config/BoardConfig.h" + +class Trackball { +public: + bool begin(); + + // Poll GPIO state changes + void update(); + + // Cursor position + int16_t cursorX() const { return _cursorX; } + int16_t cursorY() const { return _cursorY; } + bool isClicked() const { return _clicked; } + bool hadMovement() const { return _hadMovement; } + bool wasClicked() const { return _clicked; } + + // Raw deltas from last update (before speed multiply) + int8_t lastDeltaX() const { return _lastDX; } + int8_t lastDeltaY() const { return _lastDY; } + + // Speed multiplier (1-5) + void setSpeed(uint8_t speed) { _speed = constrain(speed, 1, 5); } + +private: + static void IRAM_ATTR isrUp(); + static void IRAM_ATTR isrDown(); + static void IRAM_ATTR isrLeft(); + static void IRAM_ATTR isrRight(); + static void IRAM_ATTR isrClick(); + + int16_t _cursorX = TFT_WIDTH / 2; + int16_t _cursorY = TFT_HEIGHT / 2; + bool _clicked = false; + bool _hadMovement = false; + int8_t _lastDX = 0; + int8_t _lastDY = 0; + uint8_t _speed = 3; + + static volatile int8_t _deltaX; + static volatile int8_t _deltaY; + static volatile bool _clickFlag; + static Trackball* _instance; +}; diff --git a/src/input/HotkeyManager.cpp b/src/input/HotkeyManager.cpp new file mode 100644 index 0000000..78a1549 --- /dev/null +++ b/src/input/HotkeyManager.cpp @@ -0,0 +1,23 @@ +#include "HotkeyManager.h" + +void HotkeyManager::registerHotkey(char key, const char* name, HotkeyCallback callback) { + _hotkeys[tolower(key)] = {name, callback}; +} + +bool HotkeyManager::process(const KeyEvent& event) { + // Ctrl+key hotkeys (always active regardless of input mode) + if (event.ctrl && event.character != 0) { + char key = tolower(event.character); + auto it = _hotkeys.find(key); + if (it != _hotkeys.end()) { + Serial.printf("[HOTKEY] Ctrl+%c -> %s\n", key, it->second.name); + if (it->second.callback) { + it->second.callback(); + } + return true; + } + } + + // Tab cycling is handled in main loop (after screen gets a chance to consume) + return false; +} diff --git a/src/input/HotkeyManager.h b/src/input/HotkeyManager.h new file mode 100644 index 0000000..a76f98b --- /dev/null +++ b/src/input/HotkeyManager.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include "hal/Keyboard.h" + +class HotkeyManager { +public: + using HotkeyCallback = std::function; + + // Register a Ctrl+key hotkey + void registerHotkey(char key, const char* name, HotkeyCallback callback); + + // Register left/right arrow actions (non-Ctrl) + void setTabCycleCallback(std::function cb) { _tabCycleCb = cb; } + + // Process a key event. Returns true if consumed by hotkey. + bool process(const KeyEvent& event); + +private: + struct HotkeyEntry { + const char* name; + HotkeyCallback callback; + }; + + std::map _hotkeys; + std::function _tabCycleCb; +}; diff --git a/src/input/InputManager.cpp b/src/input/InputManager.cpp new file mode 100644 index 0000000..d4361b4 --- /dev/null +++ b/src/input/InputManager.cpp @@ -0,0 +1,85 @@ +#include "InputManager.h" + +void InputManager::begin(Keyboard* kb, Trackball* tb, TouchInput* touch) { + _kb = kb; + _tb = tb; + _touch = touch; +} + +void InputManager::update() { + _hasKey = false; + _activity = false; + + // Poll keyboard + if (_kb) { + _kb->update(); + if (_kb->hasEvent()) { + _keyEvent = _kb->getEvent(); + _hasKey = true; + _activity = true; + } + } + + // Poll trackball — convert deltas to nav KeyEvents + if (_tb) { + _tb->update(); + if (_tb->hadMovement() || _tb->wasClicked()) { + _activity = true; + } + + // Generate nav events from trackball when no keyboard key was pressed + if (!_hasKey) { + unsigned long now = millis(); + + // Accumulate deltas, clamp to ±20 + _tbAccumX += _tb->lastDeltaX(); + _tbAccumY += _tb->lastDeltaY(); + if (_tbAccumX > 20) _tbAccumX = 20; + if (_tbAccumX < -20) _tbAccumX = -20; + if (_tbAccumY > 20) _tbAccumY = 20; + if (_tbAccumY < -20) _tbAccumY = -20; + + if (now - _lastTbNavTime >= TB_NAV_RATE_MS) { + // Click → enter + if (_tb->wasClicked()) { + _keyEvent = {}; + _keyEvent.enter = true; + _hasKey = true; + _lastTbNavTime = now; + _tbAccumX = 0; + _tbAccumY = 0; + } + // Pick dominant axis — whichever accumulated more wins + else { + int8_t absX = _tbAccumX < 0 ? -_tbAccumX : _tbAccumX; + int8_t absY = _tbAccumY < 0 ? -_tbAccumY : _tbAccumY; + bool yDominant = absY >= absX; + + if (yDominant && absY >= TB_NAV_THRESHOLD) { + _keyEvent = {}; + if (_tbAccumY < 0) _keyEvent.up = true; + else _keyEvent.down = true; + _hasKey = true; + _lastTbNavTime = now; + _tbAccumX = 0; + _tbAccumY = 0; + } + else if (!yDominant && absX >= TB_NAV_THRESHOLD) { + _keyEvent = {}; + if (_tbAccumX < 0) _keyEvent.left = true; + else _keyEvent.right = true; + _hasKey = true; + _lastTbNavTime = now; + _tbAccumX = 0; + _tbAccumY = 0; + } + } + } + } + } + + // Touch is polled by LVGL indev read callback — just check for activity + if (_touch && _touch->isTouched()) { + _activity = true; + } +} diff --git a/src/input/InputManager.h b/src/input/InputManager.h new file mode 100644 index 0000000..86b510d --- /dev/null +++ b/src/input/InputManager.h @@ -0,0 +1,34 @@ +#pragma once + +#include "hal/Keyboard.h" +#include "hal/Trackball.h" +#include "hal/TouchInput.h" + +class InputManager { +public: + void begin(Keyboard* kb, Trackball* tb, TouchInput* touch); + void update(); + + // Keyboard events + bool hasKeyEvent() const { return _hasKey; } + const KeyEvent& getKeyEvent() const { return _keyEvent; } + + // Any activity (for power wake) + bool hadActivity() const { return _activity; } + +private: + Keyboard* _kb = nullptr; + Trackball* _tb = nullptr; + TouchInput* _touch = nullptr; + + bool _hasKey = false; + KeyEvent _keyEvent; + bool _activity = false; + + // Trackball navigation state + int8_t _tbAccumX = 0; + int8_t _tbAccumY = 0; + unsigned long _lastTbNavTime = 0; + static constexpr int8_t TB_NAV_THRESHOLD = 3; + static constexpr unsigned long TB_NAV_RATE_MS = 200; +}; diff --git a/src/lv_conf.h b/src/lv_conf.h new file mode 100644 index 0000000..4a22ecd --- /dev/null +++ b/src/lv_conf.h @@ -0,0 +1,64 @@ +#ifndef LV_CONF_H +#define LV_CONF_H + +#include + +// Color depth: 16-bit RGB565 +#define LV_COLOR_DEPTH 16 + +// Memory: use stdlib malloc (PSRAM-aware on ESP32-S3) +#define LV_MEM_CUSTOM 1 +#define LV_MEM_CUSTOM_INCLUDE +#define LV_MEM_CUSTOM_ALLOC malloc +#define LV_MEM_CUSTOM_FREE free +#define LV_MEM_CUSTOM_REALLOC realloc + +// Tick: custom (provided by main loop) +#define LV_TICK_CUSTOM 1 +#define LV_TICK_CUSTOM_INCLUDE "Arduino.h" +#define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) + +// Display +#define LV_HOR_RES_MAX 320 +#define LV_VER_RES_MAX 240 +#define LV_DPI_DEF 130 + +// Logging +#define LV_USE_LOG 0 + +// Fonts - built-in +#define LV_FONT_MONTSERRAT_8 1 +#define LV_FONT_MONTSERRAT_10 1 +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_UNSCII_8 1 +#define LV_FONT_DEFAULT &lv_font_montserrat_12 + +// Widgets +#define LV_USE_LABEL 1 +#define LV_USE_BTN 1 +#define LV_USE_BTNMATRIX 1 +#define LV_USE_TEXTAREA 1 +#define LV_USE_LIST 1 +#define LV_USE_BAR 1 +#define LV_USE_SLIDER 1 +#define LV_USE_SWITCH 1 +#define LV_USE_DROPDOWN 1 +#define LV_USE_ROLLER 1 +#define LV_USE_TABLE 1 +#define LV_USE_TABVIEW 1 +#define LV_USE_IMG 1 +#define LV_USE_LINE 1 +#define LV_USE_ARC 1 +#define LV_USE_SPINNER 1 +#define LV_USE_MSGBOX 1 +#define LV_USE_KEYBOARD 1 + +// Scroll +#define LV_USE_FLEX 1 +#define LV_USE_GRID 1 + +// OS +#define LV_USE_OS LV_OS_NONE + +#endif // LV_CONF_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..75189f3 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,750 @@ +// ============================================================================= +// Ratdeck v1.0 — Main Entry Point +// LilyGo T-Deck Plus: LovyanGFX Direct UI + microReticulum + LXMF Messaging +// ============================================================================= + +#include +#include +#include + +#include "config/BoardConfig.h" +#include "config/Config.h" +#include "hal/Display.h" +#include "hal/TouchInput.h" +#include "hal/Trackball.h" +#include "hal/Keyboard.h" +#include "hal/Power.h" +#include "radio/SX1262.h" +#include "input/InputManager.h" +#include "input/HotkeyManager.h" +#include "ui/UIManager.h" +#include "ui/screens/BootScreen.h" +#include "ui/screens/HomeScreen.h" +#include "ui/screens/NodesScreen.h" +#include "ui/screens/MessagesScreen.h" +#include "ui/screens/MessageView.h" +#include "ui/screens/SettingsScreen.h" +#include "ui/screens/HelpOverlay.h" +#include "ui/screens/MapScreen.h" +#include "storage/FlashStore.h" +#include "storage/SDStore.h" +#include "storage/MessageStore.h" +#include "reticulum/ReticulumManager.h" +#include "reticulum/AnnounceManager.h" +#include "reticulum/LXMFManager.h" +#include "transport/LoRaInterface.h" +#include "transport/WiFiInterface.h" +#include "transport/TCPClientInterface.h" +#include "transport/BLEInterface.h" +#include "transport/BLESideband.h" +#include "config/UserConfig.h" +#include "audio/AudioNotify.h" +#include +#include +#include +#include + +// --- Hardware --- +// Single shared SPI bus for display, LoRa, and SD card +// IMPORTANT: On ESP32-S3, Arduino FSPI=0 maps to SPI2 hardware. +// Do NOT use SPI2_HOST (IDF constant = 1) — Arduino treats index 1 as HSPI/SPI3! +SPIClass sharedSPI(FSPI); + +SX1262 radio(&sharedSPI, + LORA_CS, SPI_SCK, SPI_MOSI, SPI_MISO, + LORA_RST, LORA_IRQ, LORA_BUSY, LORA_RXEN, + LORA_HAS_TCXO, LORA_DIO2_AS_RF_SWITCH); + +Display display; +TouchInput touch; +Trackball trackball; +Keyboard keyboard; + +// --- Subsystems --- +InputManager inputManager; +HotkeyManager hotkeys; +UIManager ui; +FlashStore flash; +SDStore sdStore; +MessageStore messageStore; +ReticulumManager rns; +AnnounceManager* announceManager = nullptr; +RNS::HAnnounceHandler announceHandler; +LXMFManager lxmf; +WiFiInterface* wifiImpl = nullptr; +RNS::Interface wifiIface({RNS::Type::NONE}); +std::vector tcpClients; +std::list tcpIfaces; // Must persist — Transport stores references (list: no realloc) +BLEInterface bleInterface; +BLESideband bleSideband; +UserConfig userConfig; +Power powerMgr; +AudioNotify audio; + +// --- Screens --- +BootScreen bootScreen; +HomeScreen homeScreen; +NodesScreen nodesScreen; +MessagesScreen messagesScreen; +MessageView messageView; +SettingsScreen settingsScreen; +HelpOverlay helpOverlay; +MapScreen mapScreen; + +// Tab-screen mapping (5 tabs) +Screen* tabScreens[5] = {nullptr, nullptr, nullptr, nullptr, nullptr}; + +// --- State --- +bool radioOnline = false; +bool bootComplete = false; +bool bootLoopRecovery = false; +bool wifiSTAStarted = false; +bool wifiSTAConnected = false; +bool tcpClientsCreated = false; +unsigned long lastAutoAnnounce = 0; +unsigned long lastStatusUpdate = 0; +constexpr unsigned long ANNOUNCE_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes +constexpr unsigned long STATUS_UPDATE_MS = 1000; // 1 Hz status bar update +unsigned long lastHeartbeat = 0; +constexpr unsigned long HEARTBEAT_INTERVAL_MS = 5000; +unsigned long loopCycleStart = 0; +unsigned long maxLoopTime = 0; + +// ============================================================================= +// Hotkey callbacks +// ============================================================================= + +void onHotkeyHelp() { + helpOverlay.toggle(); + ui.setOverlay(helpOverlay.isVisible() ? &helpOverlay : nullptr); +} +void onHotkeyMessages() { + ui.tabBar().setActiveTab(TabBar::TAB_MSGS); + ui.setScreen(&messagesScreen); +} +void onHotkeyNewMsg() { + ui.tabBar().setActiveTab(TabBar::TAB_MSGS); + ui.setScreen(&messagesScreen); +} +void onHotkeySettings() { + ui.tabBar().setActiveTab(TabBar::TAB_SETUP); + ui.setScreen(&settingsScreen); +} +void onHotkeyAnnounce() { + rns.announce(); + ui.statusBar().flashAnnounce(); + ui.statusBar().showToast("Announce sent!"); +} +void onHotkeyDiag() { + Serial.println("=== DIAGNOSTIC DUMP ==="); + Serial.printf("Device: Ratdeck T-Deck Plus\n"); + Serial.printf("Identity: %s\n", rns.identityHash().c_str()); + Serial.printf("Transport: %s\n", rns.isTransportActive() ? "ACTIVE" : "OFFLINE"); + Serial.printf("Paths: %d Links: %d\n", (int)rns.pathCount(), (int)rns.linkCount()); + Serial.printf("Radio: %s\n", radioOnline ? "ONLINE" : "OFFLINE"); + if (radioOnline) { + Serial.printf("Freq: %lu Hz SF: %d BW: %lu CR: 4/%d TXP: %d dBm\n", + (unsigned long)radio.getFrequency(), + radio.getSpreadingFactor(), + (unsigned long)radio.getSignalBandwidth(), + radio.getCodingRate4(), + radio.getTxPower()); + Serial.printf("Preamble: %ld symbols\n", radio.getPreambleLength()); + uint16_t devErr = radio.getDeviceErrors(); + uint8_t status = radio.getStatus(); + Serial.printf("DevErrors: 0x%04X Status: 0x%02X (mode=%d cmd=%d)\n", + devErr, status, (status >> 4) & 0x07, (status >> 1) & 0x07); + if (devErr & 0x40) Serial.println(" *** PLL LOCK FAILED ***"); + Serial.printf("Current RSSI: %d dBm\n", radio.currentRssi()); + } + Serial.printf("Free heap: %lu bytes PSRAM: %lu bytes\n", + (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getFreePsram()); + Serial.printf("Uptime: %lu s\n", millis() / 1000); + Serial.println("======================="); +} + +volatile bool rssiMonitorActive = false; +void onHotkeyRssiMonitor() { + if (!radioOnline) { Serial.println("[RSSI] Radio offline"); return; } + Serial.println("[RSSI] Sampling for 5 seconds..."); + rssiMonitorActive = true; + int minRssi = 0, maxRssi = -200; + unsigned long start = millis(); + int samples = 0; + while (millis() - start < 5000) { + int rssi = radio.currentRssi(); + if (rssi < minRssi) minRssi = rssi; + if (rssi > maxRssi) maxRssi = rssi; + samples++; + Serial.printf("[RSSI] %d dBm\n", rssi); + delay(100); + } + rssiMonitorActive = false; + Serial.printf("[RSSI] Done: %d samples, min=%d max=%d dBm\n", samples, minRssi, maxRssi); +} + +void onHotkeyRadioTest() { + Serial.println("[TEST] Sending raw test packet..."); + uint8_t header = 0xA0; + const char* testPayload = "RATDECK_TEST_1234567890"; + radio.beginPacket(); + radio.write(header); + radio.write((const uint8_t*)testPayload, strlen(testPayload)); + bool ok = radio.endPacket(); + Serial.printf("[TEST] TX %s (%d bytes)\n", ok ? "OK" : "FAILED", (int)(1 + strlen(testPayload))); + radio.receive(); +} + +// ============================================================================= +// Helper: render boot screen immediately +// ============================================================================= +static void bootRender() { + ui.render(); +} + +// ============================================================================= +// Setup — 26-step boot sequence +// ============================================================================= + +void setup() { + // Step 1: Power pin — CRITICAL: enables all T-Deck Plus peripherals + Power::enablePeripherals(); + + // Step 2: Serial + Serial.begin(SERIAL_BAUD); + delay(100); + Serial.println(); + Serial.println("================================="); + Serial.printf(" Ratdeck v%s\n", RATDECK_VERSION_STRING); + Serial.println(" LilyGo T-Deck Plus"); + Serial.println("================================="); + + esp_reset_reason_t reason = esp_reset_reason(); + const char* reasonStr = "UNKNOWN"; + switch (reason) { + case ESP_RST_POWERON: reasonStr = "POWER_ON"; break; + case ESP_RST_SW: reasonStr = "SOFTWARE"; break; + case ESP_RST_PANIC: reasonStr = "PANIC"; break; + case ESP_RST_INT_WDT: reasonStr = "INT_WDT"; break; + case ESP_RST_TASK_WDT: reasonStr = "TASK_WDT"; break; + case ESP_RST_WDT: reasonStr = "WDT"; break; + case ESP_RST_BROWNOUT: reasonStr = "BROWNOUT"; break; + case ESP_RST_DEEPSLEEP: reasonStr = "DEEP_SLEEP"; break; + default: break; + } + Serial.printf("[BOOT] Reset: %s (%d)\n", reasonStr, (int)reason); + Serial.printf("[BOOT] Heap: %lu PSRAM: %lu\n", + (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getPsramSize()); + + // Step 3: Initialize I2C bus (shared by keyboard + touchscreen) + Wire.begin(I2C_SDA, I2C_SCL); + Wire.setClock(400000); + + // Step 3.5: Initialize shared SPI bus + sharedSPI.begin(SPI_SCK, SPI_MISO, SPI_MOSI); + // Deassert all slave CS pins to prevent bus contention + pinMode(LORA_CS, OUTPUT); digitalWrite(LORA_CS, HIGH); + pinMode(SD_CS, OUTPUT); digitalWrite(SD_CS, HIGH); + + // Step 4: Radio + SD init BEFORE display + // Radio and SD must init while SPIClass exclusively owns SPI2_HOST. + // LovyanGFX's init() later joins the bus via spi_bus_add_device(). + // This avoids any bus re-init dance that would invalidate device handles. + Serial.println("[BOOT] Initializing radio..."); + if (radio.begin(LORA_DEFAULT_FREQ)) { + radio.setSpreadingFactor(LORA_DEFAULT_SF); + radio.setSignalBandwidth(LORA_DEFAULT_BW); + radio.setCodingRate4(LORA_DEFAULT_CR); + radio.setTxPower(LORA_DEFAULT_TX_POWER); + radio.setPreambleLength(LORA_DEFAULT_PREAMBLE); + radio.receive(); + radioOnline = true; + Serial.println("[RADIO] SX1262 online at 915 MHz"); + } else { + Serial.println("[RADIO] SX1262 not detected!"); + } + + // SD card init (shared SPI, right after radio) + digitalWrite(LORA_CS, HIGH); + delay(10); + if (sdStore.begin(&sharedSPI, SD_CS)) { + sdStore.ensureDir("/ratputer"); + sdStore.ensureDir("/ratputer/config"); + sdStore.ensureDir("/ratputer/messages"); + sdStore.ensureDir("/ratputer/contacts"); + sdStore.ensureDir("/ratputer/identity"); + Serial.println("[SD] Card ready"); + } else { + Serial.println("[SD] Not detected"); + } + + // Verify radio SPI still works after SD init + if (radioOnline) { + uint8_t sw_msb = radio.readRegister(0x0740); + uint8_t sw_lsb = radio.readRegister(0x0741); + Serial.printf("[BOOT] Radio SPI pre-display: syncword=0x%02X%02X %s\n", + sw_msb, sw_lsb, (sw_msb == 0xFF && sw_lsb == 0xFF) ? "DEAD!" : "OK"); + } + + // Step 5: Display HAL — LovyanGFX + ST7789V + // LovyanGFX's Bus_SPI::init() calls spi_bus_initialize() which will + // return ESP_ERR_INVALID_STATE (bus already owned by SPIClass) and + // then spi_bus_add_device() to join the existing bus. Both LGFX and + // SPIClass get valid device handles on the same SPI2_HOST bus. + display.begin(); + Serial.println("[BOOT] Display initialized (LovyanGFX direct)"); + + // Verify radio SPI survives display init + if (radioOnline) { + uint8_t sw_msb = radio.readRegister(0x0740); + uint8_t sw_lsb = radio.readRegister(0x0741); + Serial.printf("[BOOT] Radio SPI post-display: syncword=0x%02X%02X %s\n", + sw_msb, sw_lsb, (sw_msb == 0xFF && sw_lsb == 0xFF) ? "DEAD!" : "OK"); + } + + // Step 6: UI manager + ui.begin(&display.gfx()); + ui.setBootMode(true); + ui.setScreen(&bootScreen); + ui.statusBar().setLoRaOnline(radioOnline); + bootScreen.setProgress(0.45f, radioOnline ? "Radio online" : "Radio FAILED"); + bootRender(); + + // Step 7: Touch HAL — GT911 I2C + touch.begin(); + bootScreen.setProgress(0.50f, "Touch ready"); + bootRender(); + + // Step 8: Keyboard HAL — ESP32-C3 I2C + keyboard.begin(); + bootScreen.setProgress(0.52f, "Keyboard ready"); + bootRender(); + + // Step 9: Trackball HAL — GPIO interrupts + trackball.begin(); + bootScreen.setProgress(0.54f, "Trackball ready"); + bootRender(); + + // Step 10: Input manager + inputManager.begin(&keyboard, &trackball, &touch); + bootScreen.setProgress(0.55f, "Input ready"); + bootRender(); + + // Step 11: Register hotkeys + hotkeys.registerHotkey('h', "Help", onHotkeyHelp); + hotkeys.registerHotkey('m', "Messages", onHotkeyMessages); + hotkeys.registerHotkey('n', "New Message", onHotkeyNewMsg); + hotkeys.registerHotkey('s', "Settings", onHotkeySettings); + hotkeys.registerHotkey('a', "Announce", onHotkeyAnnounce); + hotkeys.registerHotkey('d', "Diagnostics", onHotkeyDiag); + hotkeys.registerHotkey('t', "Radio Test", onHotkeyRadioTest); + hotkeys.registerHotkey('r', "RSSI Monitor", onHotkeyRssiMonitor); + hotkeys.setTabCycleCallback([](int dir) { + ui.tabBar().cycleTab(dir); + int tab = ui.tabBar().getActiveTab(); + if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); + }); + bootScreen.setProgress(0.58f, "Hotkeys registered"); + bootRender(); + + // Step 12: Mount LittleFS + bootScreen.setProgress(0.60f, "Mounting flash..."); + bootRender(); + if (!flash.begin()) { + Serial.println("[BOOT] Flash init failed, formatting..."); + if (flash.format()) { + Serial.println("[BOOT] LittleFS formatted and mounted"); + } else { + Serial.println("[BOOT] LittleFS format failed!"); + } + } else { + Serial.println("[BOOT] LittleFS mounted OK"); + } + + // Step 13: Boot loop detection (NVS) + { + Preferences prefs; + if (prefs.begin("ratdeck", false)) { + int bc = prefs.getInt("bootc", 0); + prefs.putInt("bootc", bc + 1); + prefs.end(); + if (bc >= 3) { + Serial.printf("[BOOT] Boot loop detected (%d failures)\n", bc); + bootLoopRecovery = true; + } + } + } + + bootScreen.setProgress(0.65f, "Starting Reticulum..."); + bootRender(); + rns.setSDStore(&sdStore); + if (rns.begin(&radio, &flash)) { + Serial.printf("[BOOT] Identity: %s\n", rns.identityHash().c_str()); + bootScreen.setProgress(0.72f, "Reticulum active"); + } else { + Serial.println("[BOOT] Reticulum init failed!"); + bootScreen.setProgress(0.72f, "RNS: FAILED"); + } + bootRender(); + + // Step 16: Message store + bootScreen.setProgress(0.72f, "Starting messaging..."); + bootRender(); + messageStore.begin(&flash, &sdStore); + + // Step 17: LXMF init + lxmf.begin(&rns, &messageStore); + lxmf.setMessageCallback([](const LXMFMessage& msg) { + Serial.printf("[LXMF] Message from %s\n", msg.sourceHash.toHex().substr(0, 8).c_str()); + ui.tabBar().setUnreadCount(TabBar::TAB_MSGS, lxmf.unreadCount()); + audio.playMessage(); + }); + bootScreen.setProgress(0.75f, "LXMF ready"); + bootRender(); + + // Step 18: Announce manager + bootScreen.setProgress(0.78f, "Loading contacts..."); + bootRender(); + announceManager = new AnnounceManager(); + announceManager->setStorage(&sdStore, &flash); + announceManager->loadContacts(); + announceHandler = RNS::HAnnounceHandler(announceManager); + RNS::Transport::register_announce_handler(announceHandler); + + // Step 19: User config load + bootScreen.setProgress(0.82f, "Loading config..."); + bootRender(); + userConfig.load(sdStore, flash); + + // Step 20: Boot loop recovery + if (bootLoopRecovery) { + userConfig.settings().wifiMode = RAT_WIFI_OFF; + Serial.println("[BOOT] WiFi forced OFF (boot loop recovery)"); + } + bootScreen.setProgress(0.83f, "Config loaded"); + bootRender(); + + // Force radio to BoardConfig defaults (override stale saved config) + { + auto& s = userConfig.settings(); + s.loraFrequency = LORA_DEFAULT_FREQ; + s.loraSF = LORA_DEFAULT_SF; + s.loraBW = LORA_DEFAULT_BW; + s.loraCR = LORA_DEFAULT_CR; + s.loraTxPower = LORA_DEFAULT_TX_POWER; + } + + // Step 21: Apply radio config + if (radioOnline) { + auto& s = userConfig.settings(); + radio.setFrequency(s.loraFrequency); + radio.setSpreadingFactor(s.loraSF); + radio.setSignalBandwidth(s.loraBW); + radio.setCodingRate4(s.loraCR); + radio.setTxPower(s.loraTxPower); + radio.receive(); + Serial.printf("[BOOT] Radio: %lu Hz, SF%d, BW%lu, CR4/%d, %d dBm\n", + (unsigned long)s.loraFrequency, s.loraSF, + (unsigned long)s.loraBW, s.loraCR, s.loraTxPower); + } + bootScreen.setProgress(0.84f, "Radio configured"); + bootRender(); + + // Step 22: WiFi start + RatWiFiMode wifiMode = userConfig.settings().wifiMode; + if (wifiMode == RAT_WIFI_AP) { + bootScreen.setProgress(0.87f, "Starting WiFi AP..."); + bootRender(); + wifiImpl = new WiFiInterface("WiFi.AP"); + if (!userConfig.settings().wifiAPSSID.isEmpty()) { + wifiImpl->setAPCredentials( + userConfig.settings().wifiAPSSID.c_str(), + userConfig.settings().wifiAPPassword.c_str()); + } + wifiIface = wifiImpl; + wifiIface.mode(RNS::Type::Interface::MODE_GATEWAY); + RNS::Transport::register_interface(wifiIface); + wifiImpl->start(); + ui.statusBar().setWiFiActive(true); + } else if (wifiMode == RAT_WIFI_STA) { + bootScreen.setProgress(0.87f, "WiFi STA starting..."); + bootRender(); + if (!userConfig.settings().wifiSTASSID.isEmpty()) { + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + WiFi.begin(userConfig.settings().wifiSTASSID.c_str(), + userConfig.settings().wifiSTAPassword.c_str()); + wifiSTAStarted = true; + Serial.printf("[WIFI] STA: %s\n", userConfig.settings().wifiSTASSID.c_str()); + } + } else { + bootScreen.setProgress(0.87f, "WiFi disabled"); + bootRender(); + } + + // Step 23: BLE start + bootScreen.setProgress(0.90f, "BLE..."); + bootRender(); + if (userConfig.settings().bleEnabled) { + bleInterface.setSideband(&bleSideband); + + if (bleInterface.start()) { + static RNS::Interface bleIface(&bleInterface); + bleIface.mode(RNS::Type::Interface::MODE_GATEWAY); + RNS::Transport::register_interface(bleIface); + + bleSideband.begin(bleInterface.getServer()); + bleSideband.setPacketCallback([](const uint8_t* data, size_t len) { + RNS::Bytes pkt(data, len); + bleInterface.injectIncoming(pkt); + }); + + ui.statusBar().setBLEActive(true); + Serial.println("[BLE] Transport + Sideband ready"); + } + } else { + Serial.println("[BLE] Disabled by config"); + } + + // Step 24: Power manager + bootScreen.setProgress(0.92f, "Power manager..."); + bootRender(); + powerMgr.begin(); + powerMgr.setDimTimeout(userConfig.settings().screenDimTimeout); + powerMgr.setOffTimeout(userConfig.settings().screenOffTimeout); + powerMgr.setBrightness(userConfig.settings().brightness); + + // Step 25: Audio init + bootScreen.setProgress(0.94f, "Audio..."); + bootRender(); + audio.setEnabled(userConfig.settings().audioEnabled); + audio.setVolume(userConfig.settings().audioVolume); + audio.begin(); + + // Boot complete — transition to Home screen + delay(200); + bootScreen.setProgress(1.0f, "Ready"); + bootRender(); + audio.playBoot(); + delay(400); + + bootComplete = true; + ui.setBootMode(false); + ui.statusBar().setTransportMode("Ratdeck"); + + // Wire up screen dependencies + homeScreen.setReticulumManager(&rns); + homeScreen.setRadio(&radio); + homeScreen.setUserConfig(&userConfig); + + nodesScreen.setAnnounceManager(announceManager); + nodesScreen.setNodeSelectedCallback([](const std::string& peerHex) { + messageView.setPeerHex(peerHex); + ui.tabBar().setActiveTab(TabBar::TAB_MSGS); + ui.setScreen(&messageView); + }); + + messagesScreen.setLXMFManager(&lxmf); + messagesScreen.setOpenCallback([](const std::string& peerHex) { + messageView.setPeerHex(peerHex); + ui.setScreen(&messageView); + }); + + messageView.setLXMFManager(&lxmf); + messageView.setBackCallback([]() { + ui.setScreen(&messagesScreen); + }); + + settingsScreen.setUserConfig(&userConfig); + settingsScreen.setFlashStore(&flash); + settingsScreen.setSDStore(&sdStore); + settingsScreen.setRadio(&radio); + settingsScreen.setAudio(&audio); + settingsScreen.setPower(&powerMgr); + settingsScreen.setWiFi(wifiImpl); + settingsScreen.setTCPClients(&tcpClients); + settingsScreen.setRNS(&rns); + settingsScreen.setUIManager(&ui); + settingsScreen.setIdentityHash(rns.identityHash()); + settingsScreen.setSaveCallback([]() -> bool { + bool ok = userConfig.save(sdStore, flash); + Serial.printf("[CONFIG] Save %s\n", ok ? "OK" : "FAILED"); + return ok; + }); + + // Tab bar callbacks + tabScreens[TabBar::TAB_HOME] = &homeScreen; + tabScreens[TabBar::TAB_MSGS] = &messagesScreen; + tabScreens[TabBar::TAB_NODES] = &nodesScreen; + tabScreens[TabBar::TAB_MAP] = &mapScreen; + tabScreens[TabBar::TAB_SETUP] = &settingsScreen; + + ui.tabBar().setTabCallback([](int tab) { + if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); + }); + + ui.setScreen(&homeScreen); + ui.tabBar().setActiveTab(TabBar::TAB_HOME); + + // Initial announce + rns.announce(); + lastAutoAnnounce = millis(); + Serial.println("[BOOT] Initial announce sent"); + + // Clear boot loop counter — we survived! + { + Preferences prefs; + if (prefs.begin("ratdeck", false)) { + prefs.putInt("bootc", 0); + prefs.end(); + } + } + + Serial.println("[BOOT] Ratdeck ready"); + Serial.printf("[BOOT] Summary: radio=%s flash=%s sd=%s\n", + radioOnline ? "ONLINE" : "OFFLINE", + flash.isReady() ? "OK" : "FAIL", + sdStore.isReady() ? "OK" : "FAIL"); +} + +// ============================================================================= +// Main Loop +// ============================================================================= + +void loop() { + // 1. Input polling + inputManager.update(); + if (inputManager.hadActivity()) { + powerMgr.activity(); + } + + // 2. Key event dispatch + if (inputManager.hasKeyEvent()) { + const KeyEvent& evt = inputManager.getKeyEvent(); + + // Help overlay intercepts all keys when visible + if (helpOverlay.isVisible()) { + helpOverlay.handleKey(evt); + ui.setOverlay(helpOverlay.isVisible() ? &helpOverlay : nullptr); + } + // Ctrl+hotkeys first + else if (!hotkeys.process(evt)) { + // Screen gets the key next + bool consumed = ui.handleKey(evt); + + // Tab cycling: ,=left /=right or Alt+J/L arrows (only if screen didn't consume) + if (!consumed && !evt.ctrl) { + if (evt.left || evt.character == ',') { + ui.tabBar().cycleTab(-1); + int tab = ui.tabBar().getActiveTab(); + if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); + } + if (evt.right || evt.character == '/') { + ui.tabBar().cycleTab(1); + int tab = ui.tabBar().getActiveTab(); + if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); + } + } + } + } + + // 3. Reticulum loop (radio RX via LoRaInterface) + rns.loop(); + + // 4. Auto-announce every 5 minutes + if (bootComplete && millis() - lastAutoAnnounce >= ANNOUNCE_INTERVAL_MS) { + lastAutoAnnounce = millis(); + rns.announce(); + ui.statusBar().flashAnnounce(); + Serial.println("[AUTO] Periodic announce"); + } + + // 5. LXMF outgoing queue + lxmf.loop(); + + // 6. WiFi STA connection handler + if (wifiSTAStarted) { + bool connected = (WiFi.status() == WL_CONNECTED); + if (connected && !wifiSTAConnected) { + wifiSTAConnected = true; + ui.statusBar().setWiFiActive(true); + Serial.printf("[WIFI] STA connected: %s\n", WiFi.localIP().toString().c_str()); + + if (!tcpClientsCreated) { + tcpClientsCreated = true; + for (auto& ep : userConfig.settings().tcpConnections) { + if (ep.autoConnect) { + char name[32]; + snprintf(name, sizeof(name), "TCP.%s", ep.host.c_str()); + auto* tcp = new TCPClientInterface(ep.host.c_str(), ep.port, name); + tcpIfaces.emplace_back(tcp); + tcpIfaces.back().mode(RNS::Type::Interface::MODE_GATEWAY); + RNS::Transport::register_interface(tcpIfaces.back()); + tcp->start(); + tcpClients.push_back(tcp); + } + } + } + } else if (!connected && wifiSTAConnected) { + wifiSTAConnected = false; + ui.statusBar().setWiFiActive(false); + Serial.println("[WIFI] STA disconnected"); + } + } + + // 7. WiFi + TCP loops + if (wifiImpl) wifiImpl->loop(); + for (auto* tcp : tcpClients) { + tcp->loop(); + yield(); + } + + // 8. BLE loops + bleInterface.loop(); + bleSideband.loop(); + + // 9. Power management + powerMgr.loop(); + + // 10. Periodic status bar update (1 Hz) + render + if (millis() - lastStatusUpdate >= STATUS_UPDATE_MS) { + lastStatusUpdate = millis(); + if (powerMgr.isScreenOn()) { + ui.statusBar().setBatteryPercent(powerMgr.batteryPercent()); + ui.update(); + } + } + + // 11. Render any dirty regions + if (powerMgr.isScreenOn()) { + ui.render(); + } + + // 12. Heartbeat for crash diagnosis + { + unsigned long cycleTime = millis() - loopCycleStart; + if (cycleTime > maxLoopTime) maxLoopTime = cycleTime; + + if (millis() - lastHeartbeat >= HEARTBEAT_INTERVAL_MS) { + lastHeartbeat = millis(); + Serial.printf("[HEART] heap=%lu psram=%lu min=%lu loop=%lums nodes=%d paths=%d links=%d lxmfQ=%d up=%lus radio=%s sd=%s flash=%s\n", + (unsigned long)ESP.getFreeHeap(), + (unsigned long)ESP.getFreePsram(), + (unsigned long)ESP.getMinFreeHeap(), + maxLoopTime, + announceManager ? announceManager->nodeCount() : 0, + (int)rns.pathCount(), + (int)rns.linkCount(), + lxmf.queuedCount(), + millis() / 1000, + radioOnline ? "ON" : "OFF", + sdStore.isReady() ? "OK" : "FAIL", + flash.isReady() ? "OK" : "FAIL"); + maxLoopTime = 0; + } + } + loopCycleStart = millis(); + + yield(); + delay(5); +} diff --git a/src/power/PowerManager.cpp b/src/power/PowerManager.cpp new file mode 100644 index 0000000..eb81066 --- /dev/null +++ b/src/power/PowerManager.cpp @@ -0,0 +1,59 @@ +#include "PowerManager.h" +#include "hal/Display.h" + +void PowerManager::begin(Display* display) { + _display = display; + _lastActivity = millis(); + _state = ACTIVE; + if (_display) _display->setBrightness(_fullBrightness); +} + +void PowerManager::activity() { + _lastActivity = millis(); + if (_state != ACTIVE) { + setState(ACTIVE); + } +} + +void PowerManager::loop() { + unsigned long elapsed = millis() - _lastActivity; + + switch (_state) { + case ACTIVE: + if (_offTimeout > 0 && elapsed >= _offTimeout) { + setState(SCREEN_OFF); + } else if (_dimTimeout > 0 && elapsed >= _dimTimeout) { + setState(DIMMED); + } + break; + + case DIMMED: + if (_offTimeout > 0 && elapsed >= _offTimeout) { + setState(SCREEN_OFF); + } + break; + + case SCREEN_OFF: + // Stay off until activity() + break; + } +} + +void PowerManager::setState(State newState) { + if (newState == _state) return; + _state = newState; + + if (!_display) return; + + switch (_state) { + case ACTIVE: + _display->setBrightness(_fullBrightness); + break; + case DIMMED: + _display->setBrightness(DIM_BRIGHTNESS); + break; + case SCREEN_OFF: + _display->setBrightness(0); + break; + } +} diff --git a/src/power/PowerManager.h b/src/power/PowerManager.h new file mode 100644 index 0000000..7a40536 --- /dev/null +++ b/src/power/PowerManager.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +class Display; + +class PowerManager { +public: + enum State { ACTIVE, DIMMED, SCREEN_OFF }; + + void begin(Display* display); + void loop(); + + // Call on any user activity (keypress, touch, trackball) + void activity(); + + // Configuration (seconds) + void setDimTimeout(uint16_t seconds) { _dimTimeout = seconds * 1000UL; } + void setOffTimeout(uint16_t seconds) { _offTimeout = seconds * 1000UL; } + void setBrightness(uint8_t brightness) { _fullBrightness = brightness; } + + State state() const { return _state; } + bool isScreenOn() const { return _state != SCREEN_OFF; } + +private: + void setState(State newState); + + Display* _display = nullptr; + State _state = ACTIVE; + unsigned long _lastActivity = 0; + unsigned long _dimTimeout = 30000; // 30s + unsigned long _offTimeout = 60000; // 60s + uint8_t _fullBrightness = 255; + static constexpr uint8_t DIM_BRIGHTNESS = 64; +}; diff --git a/src/radio/RadioConstants.h b/src/radio/RadioConstants.h new file mode 100644 index 0000000..fae8fd9 --- /dev/null +++ b/src/radio/RadioConstants.h @@ -0,0 +1,102 @@ +#pragma once + +// ============================================================================= +// SX1262 Register Addresses, Opcodes, and Constants +// Direct port from Ratputer (extracted from RNode_Firmware_CE) +// ============================================================================= + +#include + +// --- Opcodes --- +#define OP_RF_FREQ_6X 0x86 +#define OP_SLEEP_6X 0x84 +#define OP_STANDBY_6X 0x80 +#define OP_TX_6X 0x83 +#define OP_RX_6X 0x82 +#define OP_PA_CONFIG_6X 0x95 +#define OP_SET_IRQ_FLAGS_6X 0x08 +#define OP_CLEAR_IRQ_STATUS_6X 0x02 +#define OP_GET_IRQ_STATUS_6X 0x12 +#define OP_RX_BUFFER_STATUS_6X 0x13 +#define OP_PACKET_STATUS_6X 0x14 +#define OP_CURRENT_RSSI_6X 0x15 +#define OP_MODULATION_PARAMS_6X 0x8B +#define OP_PACKET_PARAMS_6X 0x8C +#define OP_STATUS_6X 0xC0 +#define OP_TX_PARAMS_6X 0x8E +#define OP_PACKET_TYPE_6X 0x8A +#define OP_BUFFER_BASE_ADDR_6X 0x8F +#define OP_READ_REGISTER_6X 0x1D +#define OP_WRITE_REGISTER_6X 0x0D +#define OP_DIO3_TCXO_CTRL_6X 0x97 +#define OP_DIO2_RF_CTRL_6X 0x9D +#define OP_CAD_PARAMS 0x88 +#define OP_CALIBRATE_6X 0x89 +#define OP_RX_TX_FALLBACK_MODE_6X 0x93 +#define OP_REGULATOR_MODE_6X 0x96 +#define OP_CALIBRATE_IMAGE_6X 0x98 +#define OP_GET_DEVICE_ERRORS_6X 0x17 +#define OP_CLEAR_DEVICE_ERRORS_6X 0x07 + +// --- FIFO --- +#define OP_FIFO_WRITE_6X 0x0E +#define OP_FIFO_READ_6X 0x1E + +// --- Calibration --- +#define MASK_CALIBRATE_ALL 0x7F + +// --- IRQ Masks --- +#define IRQ_TX_DONE_MASK_6X 0x01 +#define IRQ_RX_DONE_MASK_6X 0x02 +#define IRQ_PREAMBLE_DET_MASK_6X 0x04 +#define IRQ_HEADER_DET_MASK_6X 0x10 +#define IRQ_PAYLOAD_CRC_ERROR_MASK_6X 0x40 +#define IRQ_ALL_MASK_6X 0b0100001111111111 + +// --- Register Addresses --- +#define REG_OCP_6X 0x08E7 +#define REG_LNA_6X 0x08AC +#define REG_SYNC_WORD_MSB_6X 0x0740 +#define REG_SYNC_WORD_LSB_6X 0x0741 +#define REG_PAYLOAD_LENGTH_6X 0x0702 +#define REG_RANDOM_GEN_6X 0x0819 +#define REG_IQ_POLARITY_6X 0x0736 +#define REG_TX_CLAMP_CONFIG_6X 0x08D8 + +// --- Modes --- +#define MODE_LONG_RANGE_MODE_6X 0x01 +#define MODE_STDBY_RC_6X 0x00 +#define MODE_STDBY_XOSC_6X 0x01 +#define MODE_FALLBACK_STDBY_RC_6X 0x20 +#define MODE_FALLBACK_STDBY_XOSC_6X 0x30 +#define MODE_IMPLICIT_HEADER 0x01 +#define MODE_EXPLICIT_HEADER 0x00 + +// --- TCXO Voltage Settings --- +#define MODE_TCXO_3_3V_6X 0x07 +#define MODE_TCXO_3_0V_6X 0x06 +#define MODE_TCXO_2_7V_6X 0x05 +#define MODE_TCXO_2_4V_6X 0x04 +#define MODE_TCXO_2_2V_6X 0x03 +#define MODE_TCXO_1_8V_6X 0x02 +#define MODE_TCXO_1_7V_6X 0x01 +#define MODE_TCXO_1_6V_6X 0x00 + +// --- Sync Word --- +#define SYNC_WORD_6X 0x1424 + +// --- OCP --- +#define OCP_TUNED 0x38 + +// --- Frequency Calculation --- +#define XTAL_FREQ_6X (double)32000000 +#define FREQ_DIV_6X (double)pow(2.0, 25.0) +#define FREQ_STEP_6X (double)(XTAL_FREQ_6X / FREQ_DIV_6X) + +// --- TX Timeout Multiplier --- +#define MODEM_TIMEOUT_MULT 1.5 + +// --- LoRa PHY Constants --- +#define PHY_HEADER_LORA_SYMBOLS 8 +#define PHY_CRC_LORA_BITS 16 +#define LORA_PREAMBLE_SYMBOLS_MIN 18 diff --git a/src/radio/SX1262.cpp b/src/radio/SX1262.cpp new file mode 100644 index 0000000..167f7ae --- /dev/null +++ b/src/radio/SX1262.cpp @@ -0,0 +1,677 @@ +// ============================================================================= +// SX1262 LoRa Radio Driver — Direct port from Ratputer +// Only change: pin assignments via BoardConfig.h (T-Deck Plus) +// ============================================================================= + +#include "SX1262.h" +#include "config/BoardConfig.h" + +SX1262* SX1262::_instance = nullptr; + +SX1262::SX1262(SPIClass* spi, int ss, int sclk, int mosi, int miso, + int reset, int irq, int busy, int rxen, + bool tcxo, bool dio2_as_rf_switch) + : _spiSettings(SPI_FREQUENCY, MSBFIRST, SPI_MODE0), + _spiModem(spi), _ss(ss), _sclk(sclk), _mosi(mosi), _miso(miso), + _reset(reset), _irq(irq), _busy(busy), _rxen(rxen), + _frequency(0), _sf(0x07), _bw(0x04), _cr(0x01), + _ldro(false), _preambleLength(LORA_PREAMBLE_SYMBOLS_MIN), + _packetIndex(0), _implicitHeaderMode(0), + _payloadLength(255), _crcMode(1), + _fifo_tx_addr_ptr(0), _fifo_rx_addr_ptr(0), + _preinitDone(false), _radioOnline(false), + _tcxo(tcxo), _dio2_as_rf_switch(dio2_as_rf_switch), + _onReceive(nullptr), _preambleDetectedAt(0), + _loraPreambleTimeMs(0), _loraHeaderTimeMs(0), _loraSymbolTimeMs(0) +{ + _txp = 14; + _instance = this; + memset(_packet, 0, sizeof(_packet)); +} + +bool SX1262::preInit() { + pinMode(_ss, OUTPUT); + digitalWrite(_ss, HIGH); + + // SPI bus is initialized by main.cpp — do NOT call _spiModem->begin() here + + long start = millis(); + uint8_t syncmsb = 0, synclsb = 0; + int probes = 0; + while (((millis() - start) < 2000) && (millis() >= start)) { + syncmsb = readRegister(REG_SYNC_WORD_MSB_6X); + synclsb = readRegister(REG_SYNC_WORD_LSB_6X); + uint16_t sw = (uint16_t)(syncmsb << 8 | synclsb); + probes++; + Serial.printf("[SX1262] preInit probe %d: syncword=0x%04X\n", probes, sw); + if (sw == 0x1424 || sw == 0x4434) { + break; + } + delay(100); + } + + uint16_t sw = (uint16_t)(syncmsb << 8 | synclsb); + if (sw != 0x1424 && sw != 0x4434) { + Serial.printf("[SX1262] preInit FAILED: syncword=0x%04X after %d probes\n", sw, probes); + return false; + } + + Serial.printf("[SX1262] preInit OK: syncword=0x%04X\n", sw); + _preinitDone = true; + return true; +} + +uint8_t IRAM_ATTR SX1262::readRegister(uint16_t address) { + return singleTransfer(OP_READ_REGISTER_6X, address, 0x00); +} + +void SX1262::writeRegister(uint16_t address, uint8_t value) { + singleTransfer(OP_WRITE_REGISTER_6X, address, value); +} + +uint8_t IRAM_ATTR SX1262::singleTransfer(uint8_t opcode, uint16_t address, uint8_t value) { + waitOnBusy(); + uint8_t response; + _spiModem->beginTransaction(_spiSettings); + digitalWrite(_ss, LOW); + _spiModem->transfer(opcode); + _spiModem->transfer((address & 0xFF00) >> 8); + _spiModem->transfer(address & 0x00FF); + if (opcode == OP_READ_REGISTER_6X) { + _spiModem->transfer(0x00); + } + response = _spiModem->transfer(value); + digitalWrite(_ss, HIGH); + _spiModem->endTransaction(); + return response; +} + +void SX1262::executeOpcode(uint8_t opcode, uint8_t* buffer, uint8_t size) { + waitOnBusy(); + _spiModem->beginTransaction(_spiSettings); + digitalWrite(_ss, LOW); + _spiModem->transfer(opcode); + for (int i = 0; i < size; i++) { + _spiModem->transfer(buffer[i]); + } + digitalWrite(_ss, HIGH); + _spiModem->endTransaction(); +} + +void SX1262::executeOpcodeRead(uint8_t opcode, uint8_t* buffer, uint8_t size) { + waitOnBusy(); + _spiModem->beginTransaction(_spiSettings); + digitalWrite(_ss, LOW); + _spiModem->transfer(opcode); + _spiModem->transfer(0x00); + for (int i = 0; i < size; i++) { + buffer[i] = _spiModem->transfer(0x00); + } + digitalWrite(_ss, HIGH); + _spiModem->endTransaction(); +} + +void SX1262::writeBuffer(const uint8_t* buffer, size_t size) { + waitOnBusy(); + _spiModem->beginTransaction(_spiSettings); + digitalWrite(_ss, LOW); + _spiModem->transfer(OP_FIFO_WRITE_6X); + _spiModem->transfer(_fifo_tx_addr_ptr); + for (size_t i = 0; i < size; i++) { + _spiModem->transfer(buffer[i]); + _fifo_tx_addr_ptr++; + } + digitalWrite(_ss, HIGH); + _spiModem->endTransaction(); +} + +void SX1262::readBuffer(uint8_t* buffer, size_t size) { + waitOnBusy(); + _spiModem->beginTransaction(_spiSettings); + digitalWrite(_ss, LOW); + _spiModem->transfer(OP_FIFO_READ_6X); + _spiModem->transfer(_fifo_rx_addr_ptr); + _spiModem->transfer(0x00); + for (size_t i = 0; i < size; i++) { + buffer[i] = _spiModem->transfer(0x00); + } + digitalWrite(_ss, HIGH); + _spiModem->endTransaction(); +} + +void SX1262::waitOnBusy() { + unsigned long t = millis(); + if (_busy != -1) { + while (digitalRead(_busy) == HIGH) { + if (millis() >= (t + 100)) break; + } + } +} + +void SX1262::reset() { + if (_reset != -1) { + pinMode(_reset, OUTPUT); + digitalWrite(_reset, LOW); + delay(10); + digitalWrite(_reset, HIGH); + delay(100); // Allow TCXO + shared SPI to stabilize + } +} + +void SX1262::calibrate() { + // Calibrate must be issued from STDBY_RC per datasheet. + // TCXO is already configured via DIO3 with sufficient timeout, + // so the 32MHz reference is available for PLL calibration. + uint8_t mode_byte = MODE_STDBY_RC_6X; + executeOpcode(OP_STANDBY_6X, &mode_byte, 1); + uint8_t cal = MASK_CALIBRATE_ALL; + executeOpcode(OP_CALIBRATE_6X, &cal, 1); + delay(5); + waitOnBusy(); +} + +void SX1262::calibrate_image(uint32_t frequency) { + uint8_t image_freq[2] = {0}; + if (frequency >= 430E6 && frequency <= 440E6) { image_freq[0] = 0x6B; image_freq[1] = 0x6F; } + else if (frequency >= 470E6 && frequency <= 510E6) { image_freq[0] = 0x75; image_freq[1] = 0x81; } + else if (frequency >= 779E6 && frequency <= 787E6) { image_freq[0] = 0xC1; image_freq[1] = 0xC5; } + else if (frequency >= 863E6 && frequency <= 870E6) { image_freq[0] = 0xD7; image_freq[1] = 0xDB; } + else if (frequency >= 902E6 && frequency <= 928E6) { image_freq[0] = 0xE1; image_freq[1] = 0xE9; } + executeOpcode(OP_CALIBRATE_IMAGE_6X, image_freq, 2); + waitOnBusy(); +} + +void SX1262::enableTCXO() { + if (_tcxo) { + // Timeout: how long SX1262 waits for TCXO to stabilize when entering + // STDBY_XOSC/TX/RX. Units = 15.625µs. 0x00A000 = 640ms (matches RadioLib). + // If too short, chip stays in STDBY_RC and calibration uses RC oscillator. + uint8_t buf[4] = {LORA_TCXO_VOLTAGE, 0x00, 0xA0, 0x00}; + executeOpcode(OP_DIO3_TCXO_CTRL_6X, buf, 4); + } +} + +void SX1262::loraMode() { + uint8_t mode = MODE_LONG_RANGE_MODE_6X; + executeOpcode(OP_PACKET_TYPE_6X, &mode, 1); +} + +void SX1262::rxAntEnable() { + if (_rxen != -1) { + digitalWrite(_rxen, HIGH); + } +} + +bool SX1262::begin(uint32_t frequency) { + _frequency = frequency; + reset(); + if (_busy != -1) { pinMode(_busy, INPUT); } + if (!_preinitDone) { + if (!preInit()) { + return false; + } + } + if (_rxen != -1) { pinMode(_rxen, OUTPUT); } + + // Match RadioLib's proven SX1262 init sequence: + // 1. Configure TCXO via DIO3 (with generous timeout) + // 2. Enter STDBY_XOSC to actually start the TCXO + // 3. Set regulator mode + // 4. Calibrate from STDBY_RC (TCXO stays powered via DIO3) + enableTCXO(); + delay(10); + + // Force STDBY_XOSC to start the TCXO oscillator + standby(); + + // Set DC-DC regulator mode (both T-Deck Plus and Cap LoRa-1262 have inductors) + uint8_t regMode = 0x01; // 0x00=LDO (default), 0x01=DC-DC + executeOpcode(OP_REGULATOR_MODE_6X, ®Mode, 1); + + // Calibrate from STDBY_RC with TCXO already running + calibrate(); + calibrate_image(_frequency); + + // Set LoRa packet type and return to STDBY_XOSC + loraMode(); + standby(); + + // Post-calibration diagnostic + uint16_t postCalErr = getDeviceErrors(); + uint8_t iqReg = readRegister(REG_IQ_POLARITY_6X); + Serial.printf("[SX1262] Post-cal DevErrors: 0x%04X%s IQ_REG=0x%02X\n", + postCalErr, (postCalErr & 0x40) ? " *** PLL FAIL ***" : " OK", iqReg); + clearDeviceErrors(); + + setSyncWord(SYNC_WORD_6X); + + if (_dio2_as_rf_switch) { + uint8_t byte = 0x01; + executeOpcode(OP_DIO2_RF_CTRL_6X, &byte, 1); + } + + rxAntEnable(); + setFrequency(_frequency); + setTxPower(_txp); + enableCrc(); + writeRegister(REG_LNA_6X, 0x96); + + uint8_t basebuf[2] = {0}; + executeOpcode(OP_BUFFER_BASE_ADDR_6X, basebuf, 2); + setModulationParams(_sf, _bw, _cr, _ldro); + setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); + + uint8_t irqBuf[8]; + irqBuf[0] = 0xFF; irqBuf[1] = 0xFF; + irqBuf[2] = 0x00; irqBuf[3] = IRQ_RX_DONE_MASK_6X; + irqBuf[4] = 0x00; irqBuf[5] = 0x00; + irqBuf[6] = 0x00; irqBuf[7] = 0x00; + executeOpcode(OP_SET_IRQ_FLAGS_6X, irqBuf, 8); + + // Keep TCXO running between TX/RX transitions (don't fall back to RC oscillator) + uint8_t fallback = MODE_FALLBACK_STDBY_XOSC_6X; + executeOpcode(OP_RX_TX_FALLBACK_MODE_6X, &fallback, 1); + + clearDeviceErrors(); + _radioOnline = true; + return true; +} + +void SX1262::end() { + sleep(); + _spiModem->end(); + _radioOnline = false; + _preinitDone = false; +} + +int SX1262::beginPacket(int implicitHeader) { + standby(); + if (implicitHeader) { implicitHeaderMode(); } else { explicitHeaderMode(); } + _payloadLength = 0; + _fifo_tx_addr_ptr = 0; + setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); + return 1; +} + +int SX1262::endPacket() { + setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); + + uint8_t timeout[3] = {0}; + uint32_t txStart = millis(); + executeOpcode(OP_TX_6X, timeout, 3); + + uint8_t buf[2] = {0}; + executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2); + + bool timed_out = false; + uint32_t w_timeout = txStart + (uint32_t)(getAirtime(_payloadLength) * MODEM_TIMEOUT_MULT) + 2000; + while ((millis() < w_timeout) && ((buf[1] & IRQ_TX_DONE_MASK_6X) == 0)) { + buf[0] = 0x00; buf[1] = 0x00; + executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2); + yield(); + } + uint32_t txActual = millis() - txStart; + if (millis() > w_timeout) { timed_out = true; } + + if (timed_out) { + Serial.printf("[SX1262] TX TIMEOUT: payload=%d actual=%dms calc=%.0fms\n", + _payloadLength, txActual, getAirtime(_payloadLength)); + } else { + Serial.printf("[SX1262] TX OK: payload=%d actual=%dms calc=%.0fms\n", + _payloadLength, txActual, getAirtime(_payloadLength)); + } + + uint8_t mask[2] = {0x00, IRQ_TX_DONE_MASK_6X}; + executeOpcode(OP_CLEAR_IRQ_STATUS_6X, mask, 2); + return !timed_out; +} + +size_t SX1262::write(uint8_t byte) { return write(&byte, 1); } + +size_t SX1262::write(const uint8_t* buffer, size_t size) { + if ((_payloadLength + size) > MAX_PACKET_SIZE) { + size = MAX_PACKET_SIZE - _payloadLength; + } + writeBuffer(buffer, size); + _payloadLength += size; + return size; +} + +void SX1262::receive(int size) { + uint8_t clear[2] = {0xFF, 0xFF}; + executeOpcode(OP_CLEAR_IRQ_STATUS_6X, clear, 2); + + if (size > 0) { + implicitHeaderMode(); + _payloadLength = size; + setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); + } else { + explicitHeaderMode(); + } + + if (_rxen != -1) { rxAntEnable(); } + uint8_t mode[3] = {0xFF, 0xFF, 0xFF}; + executeOpcode(OP_RX_6X, mode, 3); +} + +int SX1262::parsePacket(int size) { + uint8_t buf[2] = {0}; + executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2); + if ((buf[1] & IRQ_RX_DONE_MASK_6X) == 0) { return 0; } + + uint8_t mask[2] = {0x00, IRQ_RX_DONE_MASK_6X}; + executeOpcode(OP_CLEAR_IRQ_STATUS_6X, mask, 2); + + // Read buffer info + uint8_t rxinfo[2] = {0}; + executeOpcodeRead(OP_RX_BUFFER_STATUS_6X, rxinfo, 2); + int pktLen = rxinfo[0]; + _fifo_rx_addr_ptr = rxinfo[1]; + + // Read RSSI/SNR before clearing IRQ + uint8_t pktStat[3] = {0}; + executeOpcodeRead(OP_PACKET_STATUS_6X, pktStat, 3); + float rssi = -float(pktStat[0]) / 2.0; + float snr = float((int8_t)pktStat[1]) * 0.25; + + bool crcOk = getPacketValidity(); + + // Always read FIFO data for diagnostics + _packetIndex = 0; + readBuffer(_packet, pktLen); + + if (!crcOk) { + Serial.printf("[SX1262] RX CRC FAIL: %d bytes RSSI=%.0f SNR=%.1f\n", + pktLen, rssi, snr); + // Full hex dump for diagnosis + for (int i = 0; i < pktLen; i++) { + Serial.printf("%02X ", _packet[i]); + if ((i & 0x1F) == 0x1F) Serial.println(); + } + Serial.println(); + receive(); + return 0; + } + + return pktLen; +} + +int IRAM_ATTR SX1262::available() { + uint8_t buf[2] = {0}; + executeOpcodeRead(OP_RX_BUFFER_STATUS_6X, buf, 2); + return buf[0] - _packetIndex; +} + +int IRAM_ATTR SX1262::read() { + if (!available()) { return -1; } + if (_packetIndex == 0) { + uint8_t rxbuf[2] = {0}; + executeOpcodeRead(OP_RX_BUFFER_STATUS_6X, rxbuf, 2); + int size = rxbuf[0]; + _fifo_rx_addr_ptr = rxbuf[1]; + readBuffer(_packet, size); + } + uint8_t byte = _packet[_packetIndex]; + _packetIndex++; + return byte; +} + +int SX1262::peek() { + if (!available()) { return -1; } + if (_packetIndex == 0) { + uint8_t rxbuf[2] = {0}; + executeOpcodeRead(OP_RX_BUFFER_STATUS_6X, rxbuf, 2); + int size = rxbuf[0]; + _fifo_rx_addr_ptr = rxbuf[1]; + readBuffer(_packet, size); + } + return _packet[_packetIndex]; +} + +void SX1262::readBytes(uint8_t* buffer, size_t size) { + for (size_t i = 0; i < size; i++) { + int b = read(); + if (b < 0) break; + buffer[i] = (uint8_t)b; + } +} + +int IRAM_ATTR SX1262::currentRssi() { + uint8_t byte = 0; + executeOpcodeRead(OP_CURRENT_RSSI_6X, &byte, 1); + return -(int(byte)) / 2; +} + +int SX1262::packetRssi() { + uint8_t buf[3] = {0}; + executeOpcodeRead(OP_PACKET_STATUS_6X, buf, 3); + return -buf[0] / 2; +} + +float SX1262::packetSnr() { + uint8_t buf[3] = {0}; + executeOpcodeRead(OP_PACKET_STATUS_6X, buf, 3); + return float((int8_t)buf[1]) * 0.25; +} + +uint16_t SX1262::getDeviceErrors() { + uint8_t buf[2] = {0}; + executeOpcodeRead(OP_GET_DEVICE_ERRORS_6X, buf, 2); + return (uint16_t)(buf[0] << 8 | buf[1]); +} + +void SX1262::clearDeviceErrors() { + uint8_t buf[2] = {0x00, 0x00}; + executeOpcode(OP_CLEAR_DEVICE_ERRORS_6X, buf, 2); +} + +uint8_t SX1262::getStatus() { + uint8_t buf[1] = {0}; + executeOpcodeRead(OP_STATUS_6X, buf, 1); + return buf[0]; +} + +uint16_t SX1262::getIrqFlags() { + uint8_t buf[2] = {0}; + executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2); + return (uint16_t)(buf[0] << 8 | buf[1]); +} + +bool IRAM_ATTR SX1262::getPacketValidity() { + uint8_t buf[2] = {0}; + executeOpcodeRead(OP_GET_IRQ_STATUS_6X, buf, 2); + executeOpcode(OP_CLEAR_IRQ_STATUS_6X, buf, 2); + return (buf[1] & IRQ_PAYLOAD_CRC_ERROR_MASK_6X) == 0; +} + +void SX1262::setFrequency(uint32_t frequency) { + _frequency = frequency; + uint32_t freq = (uint32_t)((double)frequency / (double)FREQ_STEP_6X); + uint8_t buf[4]; + buf[0] = ((freq >> 24) & 0xFF); + buf[1] = ((freq >> 16) & 0xFF); + buf[2] = ((freq >> 8) & 0xFF); + buf[3] = (freq & 0xFF); + executeOpcode(OP_RF_FREQ_6X, buf, 4); +} + +uint32_t SX1262::getFrequency() { return _frequency; } + +void SX1262::setTxPower(int level) { + writeRegister(REG_TX_CLAMP_CONFIG_6X, readRegister(REG_TX_CLAMP_CONFIG_6X) | (0x0F << 1)); + uint8_t pa_buf[4]; + pa_buf[0] = 0x04; pa_buf[1] = 0x07; pa_buf[2] = 0x00; pa_buf[3] = 0x01; + executeOpcode(OP_PA_CONFIG_6X, pa_buf, 4); + if (level > 22) level = 22; + else if (level < -9) level = -9; + _txp = level; + writeRegister(REG_OCP_6X, OCP_TUNED); + uint8_t tx_buf[2]; + tx_buf[0] = level; + tx_buf[1] = 0x02; // PA ramp: 40us + executeOpcode(OP_TX_PARAMS_6X, tx_buf, 2); +} + +int8_t SX1262::getTxPower() { return _txp; } + +void SX1262::setSpreadingFactor(int sf) { + if (sf < 5) sf = 5; + else if (sf > 12) sf = 12; + _sf = sf; + handleLowDataRate(); + setModulationParams(sf, _bw, _cr, _ldro); +} + +uint8_t SX1262::getSpreadingFactor() { return _sf; } + +uint32_t SX1262::getSignalBandwidth() { + switch (_bw) { + case 0x00: return 7800; case 0x01: return 15600; + case 0x02: return 31250; case 0x03: return 62500; + case 0x04: return 125000; case 0x05: return 250000; + case 0x06: return 500000; case 0x08: return 10400; + case 0x09: return 20800; case 0x0A: return 41700; + } + return 0; +} + +void SX1262::setSignalBandwidth(uint32_t sbw) { + if (sbw <= 7800) _bw = 0x00; + else if (sbw <= 10400) _bw = 0x08; + else if (sbw <= 15600) _bw = 0x01; + else if (sbw <= 20800) _bw = 0x09; + else if (sbw <= 31250) _bw = 0x02; + else if (sbw <= 41700) _bw = 0x0A; + else if (sbw <= 62500) _bw = 0x03; + else if (sbw <= 125000) _bw = 0x04; + else if (sbw <= 250000) _bw = 0x05; + else _bw = 0x06; + handleLowDataRate(); + setModulationParams(_sf, _bw, _cr, _ldro); +} + +void SX1262::setCodingRate4(int denominator) { + if (denominator < 5) denominator = 5; + else if (denominator > 8) denominator = 8; + _cr = denominator - 4; + setModulationParams(_sf, _bw, _cr, _ldro); +} + +uint8_t SX1262::getCodingRate4() { return _cr + 4; } + +void SX1262::setPreambleLength(long length) { + _preambleLength = length; + setPacketParams(length, _implicitHeaderMode, _payloadLength, _crcMode); +} + +void SX1262::enableCrc() { + _crcMode = 1; + setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); +} + +void SX1262::disableCrc() { + _crcMode = 0; + setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); +} + +void SX1262::setModulationParams(uint8_t sf, uint8_t bw, uint8_t cr, int ldro) { + // SetModulationParams is only valid in STDBY mode (SX1262 DS Table 11-2). + // Calling from RX/TX mode is silently rejected by the hardware. + standby(); + uint8_t buf[4] = {sf, bw, cr, (uint8_t)ldro}; + executeOpcode(OP_MODULATION_PARAMS_6X, buf, 4); +} + +void SX1262::setPacketParams(uint32_t preamble, uint8_t headermode, uint8_t length, uint8_t crc) { + uint8_t buf[6]; + buf[0] = (uint8_t)((preamble & 0xFF00) >> 8); + buf[1] = (uint8_t)(preamble & 0x00FF); + buf[2] = headermode; + buf[3] = length; + buf[4] = crc; + buf[5] = 0x00; // Standard IQ (no inversion) + executeOpcode(OP_PACKET_PARAMS_6X, buf, 6); + + // SX1262 errata 15.1: IQ polarity register must be corrected after SetPacketParams. + // For standard IQ (no inversion), bit 2 of register 0x0736 must be SET. + // For inverted IQ, bit 2 must be CLEARED. (RadioLib: SX126x.cpp) + uint8_t iqReg = readRegister(REG_IQ_POLARITY_6X); + iqReg |= 0x04; // Standard IQ: set bit 2 + writeRegister(REG_IQ_POLARITY_6X, iqReg); +} + +void SX1262::setSyncWord(uint16_t sw) { + writeRegister(REG_SYNC_WORD_MSB_6X, 0x14); + writeRegister(REG_SYNC_WORD_LSB_6X, 0x24); +} + +void SX1262::explicitHeaderMode() { + _implicitHeaderMode = 0; + setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); +} + +void SX1262::implicitHeaderMode() { + _implicitHeaderMode = 1; + setPacketParams(_preambleLength, _implicitHeaderMode, _payloadLength, _crcMode); +} + +void SX1262::handleLowDataRate() { + _ldro = long((1 << _sf) / (getSignalBandwidth() / 1000)) > 16; +} + +void SX1262::standby() { + uint8_t byte = _tcxo ? MODE_STDBY_XOSC_6X : MODE_STDBY_RC_6X; + executeOpcode(OP_STANDBY_6X, &byte, 1); +} + +void SX1262::sleep() { + uint8_t byte = 0x00; + executeOpcode(OP_SLEEP_6X, &byte, 1); +} + +float SX1262::getAirtime(uint16_t written) { + if (!_radioOnline) return 0; + float symbolRate = (float)getSignalBandwidth() / (float)(1 << _sf); + float symbolTimeMs = 1000.0 / symbolRate; + float loraSymbols; + if (_sf >= 7) { + loraSymbols = (8.0 * written + PHY_CRC_LORA_BITS - 4.0 * _sf + 8 + PHY_HEADER_LORA_SYMBOLS); + loraSymbols /= 4.0 * (_sf - 2 * (_ldro ? 1 : 0)); + if (loraSymbols < 0) loraSymbols = 0; + loraSymbols = ceil(loraSymbols); + loraSymbols += _preambleLength + 0.25 + 8; + } else { + loraSymbols = (8.0 * written + PHY_CRC_LORA_BITS - 4.0 * _sf + PHY_HEADER_LORA_SYMBOLS); + loraSymbols /= 4.0 * _sf; + if (loraSymbols < 0) loraSymbols = 0; + loraSymbols = ceil(loraSymbols); + loraSymbols += _preambleLength + 2.25 + 8; + } + return loraSymbols * symbolTimeMs; +} + +void IRAM_ATTR SX1262::onDio0Rise() { + if (_instance) { _instance->handleDio0Rise(); } +} + +void SX1262::handleDio0Rise() { + _packetIndex = 0; + uint8_t rxbuf[2] = {0}; + executeOpcodeRead(OP_RX_BUFFER_STATUS_6X, rxbuf, 2); + int packetLength = rxbuf[0]; + if (_onReceive) { _onReceive(packetLength); } +} + +void SX1262::onReceive(void(*callback)(int)) { + _onReceive = callback; + if (callback) { + pinMode(_irq, INPUT); + uint8_t buf[8] = {0xFF, 0xFF, 0x00, IRQ_RX_DONE_MASK_6X, 0x00, 0x00, 0x00, 0x00}; + executeOpcode(OP_SET_IRQ_FLAGS_6X, buf, 8); + attachInterrupt(digitalPinToInterrupt(_irq), onDio0Rise, RISING); + } else { + detachInterrupt(digitalPinToInterrupt(_irq)); + } +} + +uint8_t SX1262::random() { return readRegister(REG_RANDOM_GEN_6X); } diff --git a/src/radio/SX1262.h b/src/radio/SX1262.h new file mode 100644 index 0000000..f18770f --- /dev/null +++ b/src/radio/SX1262.h @@ -0,0 +1,140 @@ +#pragma once + +// ============================================================================= +// SX1262 LoRa Radio Driver — Direct port from Ratputer +// Pin assignments come from BoardConfig.h (T-Deck Plus pins) +// ============================================================================= + +#include +#include +#include "RadioConstants.h" +#include "config/BoardConfig.h" + +class SX1262 { +public: + SX1262(SPIClass* spi, int ss, int sclk, int mosi, int miso, + int reset, int irq, int busy, int rxen = -1, + bool tcxo = true, bool dio2_as_rf_switch = true); + + // --- Lifecycle --- + bool begin(uint32_t frequency); + void end(); + + // --- TX --- + int beginPacket(int implicitHeader = 0); + int endPacket(); + size_t write(uint8_t byte); + size_t write(const uint8_t* buffer, size_t size); + + // --- RX --- + void receive(int size = 0); + int available(); + int read(); + int peek(); + int parsePacket(int size = 0); + void readBytes(uint8_t* buffer, size_t size); + + // --- Configuration --- + void setFrequency(uint32_t frequency); + uint32_t getFrequency(); + void setTxPower(int level); + int8_t getTxPower(); + void setSpreadingFactor(int sf); + uint8_t getSpreadingFactor(); + void setSignalBandwidth(uint32_t sbw); + uint32_t getSignalBandwidth(); + void setCodingRate4(int denominator); + uint8_t getCodingRate4(); + void setPreambleLength(long length); + void enableCrc(); + void disableCrc(); + + // --- Status --- + int currentRssi(); + int packetRssi(); + float packetSnr(); + bool isRadioOnline() { return _radioOnline; } + long getPreambleLength() const { return _preambleLength; } + uint8_t readRegister(uint16_t address); + uint16_t getDeviceErrors(); + void clearDeviceErrors(); + uint8_t getStatus(); + uint16_t getIrqFlags(); + + // --- FIFO access --- + void readBuffer(uint8_t* buffer, size_t size); + const uint8_t* packetBuffer() const { return _packet; } + + // --- Interrupt-driven RX --- + void onReceive(void(*callback)(int)); + + // --- Power --- + void standby(); + void sleep(); + + // --- Misc --- + uint8_t random(); + +private: + bool preInit(); + void reset(); + void writeRegister(uint16_t address, uint8_t value); + uint8_t singleTransfer(uint8_t opcode, uint16_t address, uint8_t value); + void executeOpcode(uint8_t opcode, uint8_t* buffer, uint8_t size); + void executeOpcodeRead(uint8_t opcode, uint8_t* buffer, uint8_t size); + void writeBuffer(const uint8_t* buffer, size_t size); + void waitOnBusy(); + + void loraMode(); + void rxAntEnable(); + void calibrate(); + void calibrate_image(uint32_t frequency); + void enableTCXO(); + void setModulationParams(uint8_t sf, uint8_t bw, uint8_t cr, int ldro); + void setPacketParams(uint32_t preamble, uint8_t headermode, uint8_t length, uint8_t crc); + void setSyncWord(uint16_t sw); + void explicitHeaderMode(); + void implicitHeaderMode(); + void handleLowDataRate(); + + float getAirtime(uint16_t written); + + void handleDio0Rise(); + bool getPacketValidity(); + static void IRAM_ATTR onDio0Rise(); + + SPISettings _spiSettings; + SPIClass* _spiModem; + int _ss, _sclk, _mosi, _miso; + int _reset, _irq, _busy, _rxen; + + uint32_t _frequency = 0; + uint8_t _sf = 0; + uint8_t _bw = 0; + uint8_t _cr = 0; + int8_t _txp = 0; + bool _ldro = false; + long _preambleLength = 0; + + int _packetIndex = 0; + int _implicitHeaderMode = 0; + int _payloadLength = 0; + int _crcMode = 0; + int _fifo_tx_addr_ptr = 0; + int _fifo_rx_addr_ptr = 0; + + bool _preinitDone = false; + bool _radioOnline = false; + bool _tcxo = false; + bool _dio2_as_rf_switch = false; + + uint8_t _packet[MAX_PACKET_SIZE] = {}; + void (*_onReceive)(int) = nullptr; + + unsigned long _preambleDetectedAt = 0; + long _loraPreambleTimeMs = 0; + long _loraHeaderTimeMs = 0; + float _loraSymbolTimeMs = 0; + + static SX1262* _instance; +}; diff --git a/src/reticulum/AnnounceManager.cpp b/src/reticulum/AnnounceManager.cpp new file mode 100644 index 0000000..b33027c --- /dev/null +++ b/src/reticulum/AnnounceManager.cpp @@ -0,0 +1,192 @@ +// Direct port from Ratputer — node discovery and contact persistence +#include "AnnounceManager.h" +#include "config/Config.h" +#include "storage/SDStore.h" +#include "storage/FlashStore.h" +#include +#include + +static std::string extractMsgPackName(const uint8_t* data, size_t len) { + if (len < 2) return ""; + uint8_t b = data[0]; + size_t pos = 0; + if ((b & 0xF0) == 0x90) { if ((b & 0x0F) == 0) return ""; pos = 1; } + else if (b == 0xDC && len >= 3) { pos = 3; } + else return ""; + if (pos >= len) return ""; + b = data[pos]; + size_t slen = 0; + if ((b & 0xE0) == 0xA0) { slen = b & 0x1F; pos++; } + else if (b == 0xD9 && pos + 1 < len) { slen = data[pos+1]; pos += 2; } + else if (b == 0xDA && pos + 2 < len) { slen = ((size_t)data[pos+1] << 8) | data[pos+2]; pos += 3; } + else return ""; + if (pos + slen > len) return ""; + return std::string((const char*)&data[pos], slen); +} + +static std::string sanitizeName(const std::string& raw, size_t maxLen = 16) { + std::string clean; + clean.reserve(std::min(raw.size(), maxLen)); + for (char c : raw) { + if (clean.size() >= maxLen) break; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_' || c == '.' || c == '\'') { + clean += c; + } + } + size_t start = clean.find_first_not_of(' '); + if (start == std::string::npos) return ""; + size_t end = clean.find_last_not_of(' '); + return clean.substr(start, end - start + 1); +} + +AnnounceManager::AnnounceManager(const char* aspectFilter) : RNS::AnnounceHandler(aspectFilter) {} + +void AnnounceManager::setStorage(SDStore* sd, FlashStore* flash) { _sd = sd; _flash = flash; } + +void AnnounceManager::received_announce( + const RNS::Bytes& destination_hash, + const RNS::Identity& announced_identity, + const RNS::Bytes& app_data) +{ + std::string name; + if (app_data.size() > 0) { + std::string rawName = extractMsgPackName(app_data.data(), app_data.size()); + if (rawName.empty()) rawName = app_data.toString(); + name = sanitizeName(rawName); + } + Serial.printf("[ANNOUNCE] From: %s name=\"%s\"\n", destination_hash.toHex().c_str(), name.c_str()); + + for (auto& node : _nodes) { + if (node.hash == destination_hash) { + if (!name.empty()) node.name = name; + node.lastSeen = millis(); + node.hops = RNS::Transport::hops_to(destination_hash); + if (node.saved) saveContact(node); + return; + } + } + + if ((int)_nodes.size() >= MAX_NODES) { + evictStale(); + if ((int)_nodes.size() >= MAX_NODES) { + unsigned long oldest = ULONG_MAX; + int oldestIdx = -1; + for (int i = 0; i < (int)_nodes.size(); i++) { + if (!_nodes[i].saved && _nodes[i].lastSeen < oldest) { + oldest = _nodes[i].lastSeen; + oldestIdx = i; + } + } + if (oldestIdx >= 0) _nodes.erase(_nodes.begin() + oldestIdx); + } + } + if ((int)_nodes.size() >= MAX_NODES) return; + + DiscoveredNode node; + node.hash = destination_hash; + node.name = name.empty() ? destination_hash.toHex().substr(0, 12) : name; + node.lastSeen = millis(); + node.hops = RNS::Transport::hops_to(destination_hash); + _nodes.push_back(node); +} + +const DiscoveredNode* AnnounceManager::findNode(const RNS::Bytes& hash) const { + for (const auto& n : _nodes) { if (n.hash == hash) return &n; } + return nullptr; +} + +void AnnounceManager::addManualContact(const std::string& hexHash, const std::string& name) { + RNS::Bytes hash; + hash.assignHex(hexHash.c_str()); + std::string safeName = sanitizeName(name); + for (auto& n : _nodes) { + if (n.hash == hash) { + if (!safeName.empty()) n.name = safeName; + n.saved = true; + saveContact(n); + return; + } + } + DiscoveredNode node; + node.hash = hash; + node.name = safeName.empty() ? hexHash.substr(0, 12) : safeName; + node.lastSeen = millis(); + node.saved = true; + _nodes.push_back(node); + saveContact(node); +} + +void AnnounceManager::evictStale(unsigned long maxAgeMs) { + unsigned long now = millis(); + _nodes.erase(std::remove_if(_nodes.begin(), _nodes.end(), + [now, maxAgeMs](const DiscoveredNode& n) { + return !n.saved && (now - n.lastSeen > maxAgeMs); + }), _nodes.end()); +} + +void AnnounceManager::saveContact(const DiscoveredNode& node) { + std::string hexHash = node.hash.toHex(); + JsonDocument doc; + doc["hash"] = hexHash; doc["name"] = node.name; + doc["rssi"] = node.rssi; doc["snr"] = node.snr; + doc["hops"] = node.hops; doc["lastSeen"] = node.lastSeen; + String json; + serializeJson(doc, json); + String filename = hexHash.substr(0, 16).c_str(); + filename += ".json"; + if (_sd && _sd->isReady()) { _sd->writeString((String(SD_PATH_CONTACTS) + filename).c_str(), json); } + if (_flash) { _flash->writeString((String(PATH_CONTACTS) + filename).c_str(), json); } +} + +void AnnounceManager::removeContact(const std::string& hexHash) { + String filename = hexHash.substr(0, 16).c_str(); + filename += ".json"; + if (_sd && _sd->isReady()) { _sd->remove((String(SD_PATH_CONTACTS) + filename).c_str()); } + if (_flash) { _flash->remove((String(PATH_CONTACTS) + filename).c_str()); } +} + +void AnnounceManager::loadContacts() { + int loaded = 0; + auto loadFromDir = [&](File& dir) { + File entry = dir.openNextFile(); + while (entry) { + if (!entry.isDirectory() && String(entry.name()).endsWith(".json")) { + size_t size = entry.size(); + if (size > 0 && size < 2048) { + String json = entry.readString(); + JsonDocument doc; + if (!deserializeJson(doc, json)) { + std::string hexHash = doc["hash"] | ""; + if (!hexHash.empty()) { + RNS::Bytes hash; hash.assignHex(hexHash.c_str()); + bool dup = false; + for (auto& n : _nodes) { if (n.hash == hash) { dup = true; break; } } + if (!dup) { + DiscoveredNode node; + node.hash = hash; + node.name = sanitizeName(doc["name"] | ""); + if (node.name.empty()) node.name = hexHash.substr(0, 12); + node.rssi = doc["rssi"] | 0; + node.snr = doc["snr"] | 0.0f; + node.hops = doc["hops"] | 0; + node.lastSeen = doc["lastSeen"] | (unsigned long)millis(); + node.saved = true; + _nodes.push_back(node); + loaded++; + } + } + } + } + } + entry = dir.openNextFile(); + } + }; + if (_sd && _sd->isReady()) { File dir = _sd->openDir(SD_PATH_CONTACTS); if (dir && dir.isDirectory()) loadFromDir(dir); } + if (_flash) { File dir = LittleFS.open(PATH_CONTACTS); if (dir && dir.isDirectory()) loadFromDir(dir); } + if (loaded > 0) Serial.printf("[ANNOUNCE] Loaded %d saved contacts\n", loaded); +} + +void AnnounceManager::saveContacts() { + for (const auto& n : _nodes) { if (n.saved) saveContact(n); } +} diff --git a/src/reticulum/AnnounceManager.h b/src/reticulum/AnnounceManager.h new file mode 100644 index 0000000..bb6617b --- /dev/null +++ b/src/reticulum/AnnounceManager.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include + +class SDStore; +class FlashStore; + +struct DiscoveredNode { + RNS::Bytes hash; + std::string name; + int rssi = 0; + float snr = 0; + uint8_t hops = 0; + unsigned long lastSeen = 0; + bool saved = false; +}; + +class AnnounceManager : public RNS::AnnounceHandler { +public: + AnnounceManager(const char* aspectFilter = nullptr); + virtual ~AnnounceManager() = default; + + virtual void received_announce( + const RNS::Bytes& destination_hash, + const RNS::Identity& announced_identity, + const RNS::Bytes& app_data) override; + + void setStorage(SDStore* sd, FlashStore* flash); + void saveContacts(); + void loadContacts(); + + const std::vector& nodes() const { return _nodes; } + int nodeCount() const { return _nodes.size(); } + const DiscoveredNode* findNode(const RNS::Bytes& hash) const; + void addManualContact(const std::string& hexHash, const std::string& name); + void evictStale(unsigned long maxAgeMs = 3600000); + +private: + void saveContact(const DiscoveredNode& node); + void removeContact(const std::string& hexHash); + + std::vector _nodes; + SDStore* _sd = nullptr; + FlashStore* _flash = nullptr; + static constexpr int MAX_NODES = 200; // PSRAM allows more +}; diff --git a/src/reticulum/LXMFManager.cpp b/src/reticulum/LXMFManager.cpp new file mode 100644 index 0000000..c822e59 --- /dev/null +++ b/src/reticulum/LXMFManager.cpp @@ -0,0 +1,127 @@ +// Direct port from Ratputer — LXMF messaging protocol +#include "LXMFManager.h" +#include "config/Config.h" +#include + +LXMFManager* LXMFManager::_instance = nullptr; + +bool LXMFManager::begin(ReticulumManager* rns, MessageStore* store) { + _rns = rns; _store = store; _instance = this; + RNS::Destination& dest = _rns->destination(); + dest.set_packet_callback(onPacketReceived); + dest.set_link_established_callback(onLinkEstablished); + Serial.println("[LXMF] Manager started"); + return true; +} + +void LXMFManager::loop() { + while (!_outQueue.empty()) { + LXMFMessage& msg = _outQueue.front(); + if (sendDirect(msg)) { + if (_store) { _store->saveMessage(msg); } + _outQueue.pop_front(); + } else { break; } + } +} + +bool LXMFManager::sendMessage(const RNS::Bytes& destHash, const std::string& content, const std::string& title) { + LXMFMessage msg; + msg.sourceHash = _rns->destination().hash(); + msg.destHash = destHash; + msg.timestamp = millis() / 1000.0; + msg.content = content; + msg.title = title; + msg.incoming = false; + msg.status = LXMFStatus::QUEUED; + if ((int)_outQueue.size() >= RATDECK_MAX_OUTQUEUE) { _outQueue.pop_front(); } + _outQueue.push_back(msg); + if (_store) { _store->saveMessage(msg); } + return true; +} + +bool LXMFManager::sendDirect(LXMFMessage& msg) { + RNS::Identity recipientId = RNS::Identity::recall(msg.destHash); + if (!recipientId) { + msg.status = LXMFStatus::FAILED; + return true; + } + RNS::Destination outDest(recipientId, RNS::Type::Destination::OUT, + RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + std::vector payload = msg.packFull(_rns->identity()); + if (payload.empty()) { msg.status = LXMFStatus::FAILED; return true; } + RNS::Bytes payloadBytes(payload.data(), payload.size()); + if (payloadBytes.size() > RNS::Type::Reticulum::MDU) { msg.status = LXMFStatus::FAILED; return true; } + msg.status = LXMFStatus::SENDING; + RNS::Packet packet(outDest, payloadBytes); + RNS::PacketReceipt receipt = packet.send(); + if (receipt) { + msg.status = LXMFStatus::SENT; + msg.messageId = RNS::Identity::full_hash(payloadBytes); + Serial.printf("[LXMF] Sent %d bytes\n", (int)payloadBytes.size()); + } else { + msg.status = LXMFStatus::FAILED; + } + return true; +} + +void LXMFManager::onPacketReceived(const RNS::Bytes& data, const RNS::Packet& packet) { + if (!_instance) return; + _instance->processIncoming(data.data(), data.size(), packet.destination_hash()); +} + +void LXMFManager::onLinkEstablished(RNS::Link& link) { + if (!_instance) return; + link.set_packet_callback([](const RNS::Bytes& data, const RNS::Packet& packet) { + if (!_instance) return; + _instance->processIncoming(data.data(), data.size(), packet.destination_hash()); + }); +} + +void LXMFManager::processIncoming(const uint8_t* data, size_t len, const RNS::Bytes& destHash) { + LXMFMessage msg; + if (!LXMFMessage::unpackFull(data, len, msg)) return; + if (_rns && msg.sourceHash == _rns->destination().hash()) return; + msg.destHash = destHash; + if (_store) { _store->saveMessage(msg); } + std::string peerHex = msg.sourceHash.toHex(); + _unread[peerHex]++; + if (_onMessage) { _onMessage(msg); } +} + +const std::vector& LXMFManager::conversations() const { + if (_store) return _store->conversations(); + static std::vector empty; + return empty; +} + +std::vector LXMFManager::getMessages(const std::string& peerHex) const { + if (_store) return _store->loadConversation(peerHex); + return {}; +} + +int LXMFManager::unreadCount(const std::string& peerHex) const { + if (!_unreadComputed) { const_cast(this)->computeUnreadFromDisk(); } + if (peerHex.empty()) { + int total = 0; + for (auto& kv : _unread) total += kv.second; + return total; + } + auto it = _unread.find(peerHex); + return (it != _unread.end()) ? it->second : 0; +} + +void LXMFManager::computeUnreadFromDisk() { + _unreadComputed = true; + if (!_store) return; + for (auto& conv : _store->conversations()) { + auto msgs = _store->loadConversation(conv); + int count = 0; + for (auto& m : msgs) { if (m.incoming && !m.read) count++; } + if (count > 0) _unread[conv] = count; + } +} + +void LXMFManager::markRead(const std::string& peerHex) { + _unread[peerHex] = 0; + if (_store) { _store->markConversationRead(peerHex); } +} diff --git a/src/reticulum/LXMFManager.h b/src/reticulum/LXMFManager.h new file mode 100644 index 0000000..812cb90 --- /dev/null +++ b/src/reticulum/LXMFManager.h @@ -0,0 +1,44 @@ +#pragma once + +#include "LXMFMessage.h" +#include "ReticulumManager.h" +#include "storage/MessageStore.h" +#include +#include +#include +#include +#include +#include + +class LXMFManager { +public: + using MessageCallback = std::function; + + bool begin(ReticulumManager* rns, MessageStore* store); + void loop(); + + bool sendMessage(const RNS::Bytes& destHash, const std::string& content, const std::string& title = ""); + void setMessageCallback(MessageCallback cb) { _onMessage = cb; } + int queuedCount() const { return _outQueue.size(); } + const std::vector& conversations() const; + std::vector getMessages(const std::string& peerHex) const; + int unreadCount(const std::string& peerHex = "") const; + void markRead(const std::string& peerHex); + +private: + bool sendDirect(LXMFMessage& msg); + void processIncoming(const uint8_t* data, size_t len, const RNS::Bytes& destHash); + static void onPacketReceived(const RNS::Bytes& data, const RNS::Packet& packet); + static void onLinkEstablished(RNS::Link& link); + + ReticulumManager* _rns = nullptr; + MessageStore* _store = nullptr; + MessageCallback _onMessage; + std::deque _outQueue; + + void computeUnreadFromDisk(); + mutable bool _unreadComputed = false; + mutable std::map _unread; + + static LXMFManager* _instance; +}; diff --git a/src/reticulum/LXMFMessage.cpp b/src/reticulum/LXMFMessage.cpp new file mode 100644 index 0000000..7967168 --- /dev/null +++ b/src/reticulum/LXMFMessage.cpp @@ -0,0 +1,142 @@ +// Direct port from Ratputer — LXMF message format (MsgPack wire, JSON storage) +#include "LXMFMessage.h" +#include + +static void mpPackFloat64(std::vector& buf, double val) { + buf.push_back(0xCB); + uint64_t bits; + memcpy(&bits, &val, 8); + for (int i = 7; i >= 0; i--) { + buf.push_back((bits >> (i * 8)) & 0xFF); + } +} + +static void mpPackString(std::vector& buf, const std::string& str) { + size_t len = str.size(); + if (len < 32) { + buf.push_back(0xA0 | (uint8_t)len); + } else if (len < 256) { + buf.push_back(0xD9); + buf.push_back((uint8_t)len); + } else { + buf.push_back(0xDA); + buf.push_back((len >> 8) & 0xFF); + buf.push_back(len & 0xFF); + } + buf.insert(buf.end(), str.begin(), str.end()); +} + +static bool mpReadFloat64(const uint8_t* data, size_t len, size_t& pos, double& val) { + if (pos >= len || data[pos] != 0xCB) return false; + pos++; + if (pos + 8 > len) return false; + uint64_t bits = 0; + for (int i = 0; i < 8; i++) { bits = (bits << 8) | data[pos++]; } + memcpy(&val, &bits, 8); + return true; +} + +static bool mpReadString(const uint8_t* data, size_t len, size_t& pos, std::string& str) { + if (pos >= len) return false; + uint8_t b = data[pos]; + size_t slen = 0; + if ((b & 0xE0) == 0xA0) { slen = b & 0x1F; pos++; } + else if (b == 0xD9) { pos++; if (pos >= len) return false; slen = data[pos++]; } + else if (b == 0xDA) { pos++; if (pos + 2 > len) return false; slen = ((size_t)data[pos] << 8) | data[pos + 1]; pos += 2; } + else return false; + if (pos + slen > len) return false; + str.assign((const char*)&data[pos], slen); + pos += slen; + return true; +} + +static bool mpSkipValue(const uint8_t* data, size_t len, size_t& pos) { + if (pos >= len) return false; + uint8_t b = data[pos]; + if ((b & 0xE0) == 0xA0) { pos += 1 + (b & 0x1F); return pos <= len; } + if ((b & 0xF0) == 0x80) { size_t c = b & 0x0F; pos++; for (size_t i = 0; i < c * 2; i++) { if (!mpSkipValue(data, len, pos)) return false; } return true; } + if ((b & 0xF0) == 0x90) { size_t c = b & 0x0F; pos++; for (size_t i = 0; i < c; i++) { if (!mpSkipValue(data, len, pos)) return false; } return true; } + if (b == 0xCB) { pos += 9; return pos <= len; } + if (b == 0xD9) { if (pos + 2 > len) return false; size_t s = data[pos + 1]; pos += 2 + s; return pos <= len; } + if (b == 0xDA) { if (pos + 3 > len) return false; size_t s = ((size_t)data[pos+1] << 8) | data[pos+2]; pos += 3 + s; return pos <= len; } + if ((b & 0x80) == 0x00 || (b & 0xE0) == 0xE0 || b == 0xC0 || b == 0xC2 || b == 0xC3) { pos++; return true; } + if (b == 0xCC || b == 0xD0) { pos += 2; return pos <= len; } + if (b == 0xCD || b == 0xD1) { pos += 3; return pos <= len; } + if (b == 0xCE || b == 0xD2 || b == 0xCA) { pos += 5; return pos <= len; } + if (b == 0xCF || b == 0xD3) { pos += 9; return pos <= len; } + if (b == 0xC4) { if (pos + 2 > len) return false; pos += 2 + data[pos+1]; return pos <= len; } + if (b == 0xC5) { if (pos + 3 > len) return false; pos += 3 + (((size_t)data[pos+1] << 8) | data[pos+2]); return pos <= len; } + return false; +} + +std::vector LXMFMessage::packContent(double timestamp, const std::string& content, const std::string& title) { + std::vector buf; + buf.reserve(32 + content.size() + title.size()); + buf.push_back(0x94); + mpPackFloat64(buf, timestamp); + mpPackString(buf, content); + mpPackString(buf, title); + buf.push_back(0x80); + return buf; +} + +std::vector LXMFMessage::packFull(const RNS::Identity& signingIdentity) const { + std::vector packed = packContent(timestamp, content, title); + if (sourceHash.size() < 16) return {}; + + std::vector signable; + signable.reserve(16 + packed.size()); + signable.insert(signable.end(), sourceHash.data(), sourceHash.data() + 16); + signable.insert(signable.end(), packed.begin(), packed.end()); + + RNS::Bytes signableBytes(signable.data(), signable.size()); + RNS::Bytes sig = signingIdentity.sign(signableBytes); + if (sig.size() < 64) return {}; + + std::vector payload; + payload.reserve(16 + packed.size() + 64); + payload.insert(payload.end(), sourceHash.data(), sourceHash.data() + 16); + payload.insert(payload.end(), packed.begin(), packed.end()); + payload.insert(payload.end(), sig.data(), sig.data() + 64); + return payload; +} + +bool LXMFMessage::unpackFull(const uint8_t* data, size_t len, LXMFMessage& msg) { + if (len < 93) return false; + msg.sourceHash = RNS::Bytes(data, 16); + msg.signature = RNS::Bytes(data + len - 64, 64); + + const uint8_t* content = data + 16; + size_t contentLen = len - 16 - 64; + size_t pos = 0; + + if (pos >= contentLen) return false; + uint8_t arrHeader = content[pos]; + if ((arrHeader & 0xF0) != 0x90) return false; + size_t arrLen = arrHeader & 0x0F; + if (arrLen < 3) return false; + pos++; + + if (!mpReadFloat64(content, contentLen, pos, msg.timestamp)) return false; + if (!mpReadString(content, contentLen, pos, msg.content)) return false; + if (!mpReadString(content, contentLen, pos, msg.title)) return false; + if (arrLen >= 4 && pos < contentLen) { mpSkipValue(content, contentLen, pos); } + + RNS::Bytes fullPayload(data, len); + msg.messageId = RNS::Identity::full_hash(fullPayload); + msg.incoming = true; + msg.status = LXMFStatus::DELIVERED; + return true; +} + +const char* LXMFMessage::statusStr() const { + switch (status) { + case LXMFStatus::DRAFT: return "draft"; + case LXMFStatus::QUEUED: return "queued"; + case LXMFStatus::SENDING: return "sending"; + case LXMFStatus::SENT: return "sent"; + case LXMFStatus::DELIVERED: return "delivered"; + case LXMFStatus::FAILED: return "failed"; + default: return "?"; + } +} diff --git a/src/reticulum/LXMFMessage.h b/src/reticulum/LXMFMessage.h new file mode 100644 index 0000000..4b26565 --- /dev/null +++ b/src/reticulum/LXMFMessage.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include +#include + +enum class LXMFStatus : uint8_t { + DRAFT = 0, QUEUED, SENDING, SENT, DELIVERED, FAILED +}; + +struct LXMFMessage { + RNS::Bytes sourceHash; + RNS::Bytes destHash; + double timestamp = 0; + std::string content; + std::string title; + RNS::Bytes signature; + + LXMFStatus status = LXMFStatus::DRAFT; + bool incoming = false; + bool read = false; + RNS::Bytes messageId; + + static std::vector packContent(double timestamp, const std::string& content, const std::string& title); + std::vector packFull(const RNS::Identity& signingIdentity) const; + static bool unpackFull(const uint8_t* data, size_t len, LXMFMessage& msg); + const char* statusStr() const; +}; diff --git a/src/reticulum/ReticulumManager.cpp b/src/reticulum/ReticulumManager.cpp new file mode 100644 index 0000000..b30be88 --- /dev/null +++ b/src/reticulum/ReticulumManager.cpp @@ -0,0 +1,219 @@ +// Direct port from Ratputer — microReticulum integration +#include "ReticulumManager.h" +#include "config/Config.h" +#include +#include + +bool LittleFSFileSystem::init() { return true; } +bool LittleFSFileSystem::file_exists(const char* p) { return LittleFS.exists(p); } + +size_t LittleFSFileSystem::read_file(const char* p, RNS::Bytes& data) { + File f = LittleFS.open(p, "r"); + if (!f) return 0; + size_t s = f.size(); + data = RNS::Bytes(s); + f.readBytes((char*)data.writable(s), s); + f.close(); + return s; +} + +size_t LittleFSFileSystem::write_file(const char* p, const RNS::Bytes& data) { + String path = String(p); + int lastSlash = path.lastIndexOf('/'); + if (lastSlash > 0) { + String dir = path.substring(0, lastSlash); + if (!LittleFS.exists(dir.c_str())) { LittleFS.mkdir(dir.c_str()); } + } + File f = LittleFS.open(p, "w"); + if (!f) return 0; + size_t w = f.write(data.data(), data.size()); + f.close(); + return w; +} + +RNS::FileStream LittleFSFileSystem::open_file(const char*, RNS::FileStream::MODE) { return {RNS::Type::NONE}; } +bool LittleFSFileSystem::remove_file(const char* p) { return LittleFS.remove(p); } +bool LittleFSFileSystem::rename_file(const char* f, const char* t) { return LittleFS.rename(f, t); } +bool LittleFSFileSystem::directory_exists(const char* p) { return LittleFS.exists(p); } +bool LittleFSFileSystem::create_directory(const char* p) { return LittleFS.mkdir(p); } +bool LittleFSFileSystem::remove_directory(const char* p) { return LittleFS.rmdir(p); } + +std::list LittleFSFileSystem::list_directory(const char* p) { + std::list entries; + File dir = LittleFS.open(p); + if (!dir || !dir.isDirectory()) return entries; + File f = dir.openNextFile(); + while (f) { entries.push_back(f.name()); f = dir.openNextFile(); } + return entries; +} + +size_t LittleFSFileSystem::storage_size() { return LittleFS.totalBytes(); } +size_t LittleFSFileSystem::storage_available() { return LittleFS.totalBytes() - LittleFS.usedBytes(); } + +bool ReticulumManager::begin(SX1262* radio, FlashStore* flash) { + _flash = flash; + + LittleFSFileSystem* fsImpl = new LittleFSFileSystem(); + RNS::FileSystem fs(fsImpl); + fs.init(); + RNS::Utilities::OS::register_filesystem(fs); + Serial.println("[RNS] Filesystem registered"); + + _loraImpl = new LoRaInterface(radio, "LoRa.915"); + _loraIface = _loraImpl; + _loraIface.mode(RNS::Type::Interface::MODE_GATEWAY); + RNS::Transport::register_interface(_loraIface); + if (!_loraImpl->start()) { + Serial.println("[RNS] WARNING: LoRa interface failed to start"); + } + + _reticulum = RNS::Reticulum(); + RNS::Reticulum::transport_enabled(true); + RNS::Reticulum::probe_destination_enabled(true); + // PSRAM allows larger tables than Ratputer + RNS::Transport::path_table_maxsize(64); + RNS::Transport::announce_table_maxsize(64); + _reticulum.start(); + Serial.println("[RNS] Reticulum started (Transport Node)"); + + if (!loadOrCreateIdentity()) { + Serial.println("[RNS] ERROR: Identity creation failed!"); + return false; + } + + _destination = RNS::Destination( + _identity, + RNS::Type::Destination::IN, + RNS::Type::Destination::SINGLE, + "lxmf", + "delivery" + ); + _destination.set_proof_strategy(RNS::Type::Destination::PROVE_ALL); + _destination.accepts_links(true); + + _transportActive = true; + Serial.println("[RNS] Transport node active"); + return true; +} + +bool ReticulumManager::loadOrCreateIdentity() { + // Tier 1: Flash (LittleFS) + if (_flash->exists(PATH_IDENTITY)) { + RNS::Bytes keyData; + if (RNS::Utilities::OS::read_file(PATH_IDENTITY, keyData) > 0) { + _identity = RNS::Identity(false); + if (_identity.load_private_key(keyData)) { + Serial.printf("[RNS] Identity loaded from flash: %s\n", _identity.hexhash().c_str()); + saveIdentityToAll(keyData); + return true; + } + } + } + + // Tier 2: SD card + if (_sd && _sd->isReady() && _sd->exists(SD_PATH_IDENTITY)) { + uint8_t keyBuf[128]; + size_t keyLen = 0; + if (_sd->readFile(SD_PATH_IDENTITY, keyBuf, sizeof(keyBuf), keyLen) && keyLen > 0) { + RNS::Bytes keyData(keyBuf, keyLen); + _identity = RNS::Identity(false); + if (_identity.load_private_key(keyData)) { + Serial.printf("[RNS] Identity restored from SD: %s\n", _identity.hexhash().c_str()); + saveIdentityToAll(keyData); + return true; + } + } + } + + // Tier 3: NVS (ESP32 Preferences — always available) + { + Preferences prefs; + if (prefs.begin("ratdeck_id", true)) { + size_t keyLen = prefs.getBytesLength("privkey"); + if (keyLen > 0 && keyLen <= 128) { + uint8_t keyBuf[128]; + prefs.getBytes("privkey", keyBuf, keyLen); + prefs.end(); + RNS::Bytes keyData(keyBuf, keyLen); + _identity = RNS::Identity(false); + if (_identity.load_private_key(keyData)) { + Serial.printf("[RNS] Identity restored from NVS: %s\n", _identity.hexhash().c_str()); + saveIdentityToAll(keyData); + return true; + } + } else { + prefs.end(); + } + } + } + + // No identity found anywhere — create new + _identity = RNS::Identity(); + Serial.printf("[RNS] New identity created: %s\n", _identity.hexhash().c_str()); + + RNS::Bytes privKey = _identity.get_private_key(); + if (privKey.size() > 0) { + saveIdentityToAll(privKey); + } + return true; +} + +void ReticulumManager::saveIdentityToAll(const RNS::Bytes& keyData) { + // Flash + _flash->writeAtomic(PATH_IDENTITY, keyData.data(), keyData.size()); + // SD + if (_sd && _sd->isReady()) { + _sd->ensureDir("/ratputer/identity"); + _sd->writeAtomic(SD_PATH_IDENTITY, keyData.data(), keyData.size()); + } + // NVS (always available, survives flash/SD failures) + Preferences prefs; + if (prefs.begin("ratdeck_id", false)) { + prefs.putBytes("privkey", keyData.data(), keyData.size()); + prefs.end(); + Serial.println("[RNS] Identity saved to NVS"); + } +} + +void ReticulumManager::loop() { + if (!_transportActive) return; + _reticulum.loop(); + if (_loraImpl) { _loraImpl->loop(); } + unsigned long now = millis(); + if (now - _lastPersist >= PATH_PERSIST_INTERVAL_MS) { + _lastPersist = now; + persistData(); + } +} + +void ReticulumManager::persistData() { RNS::Transport::persist_data(); } + +String ReticulumManager::identityHash() const { + if (!_identity) return "unknown"; + std::string hex = _identity.hexhash(); + if (hex.length() >= 12) { + return String((hex.substr(0, 4) + ":" + hex.substr(4, 4) + ":" + hex.substr(8, 4)).c_str()); + } + return String(hex.c_str()); +} + +size_t ReticulumManager::pathCount() const { return _reticulum.get_path_table().size(); } +size_t ReticulumManager::linkCount() const { return _reticulum.get_link_count(); } + +void ReticulumManager::announce(const RNS::Bytes& appData) { + if (!_transportActive) return; + Serial.printf("[TX-DBG] dest_hash: %s\n", _destination.hash().toHex().c_str()); + Serial.printf("[TX-DBG] identity_hash: %s\n", _identity.hexhash().c_str()); + Serial.printf("[TX-DBG] public_key: %s\n", _identity.get_public_key().toHex().c_str()); + // Compute name_hash the same way Destination does: SHA256("lxmf.delivery").left(10) + RNS::Bytes nh = RNS::Identity::full_hash(RNS::Bytes("lxmf.delivery")).left(10); + Serial.printf("[TX-DBG] name_hash: %s\n", nh.toHex().c_str()); + // Compute hash_material = name_hash + identity_hash + RNS::Bytes hm = nh + _identity.hash(); + Serial.printf("[TX-DBG] hash_material: %s\n", hm.toHex().c_str()); + RNS::Bytes eh = RNS::Identity::full_hash(hm).left(16); + Serial.printf("[TX-DBG] recomputed: %s\n", eh.toHex().c_str()); + _destination.announce(appData); + _lastAnnounceTime = millis(); + Serial.println("[RNS] Announce sent"); +} diff --git a/src/reticulum/ReticulumManager.h b/src/reticulum/ReticulumManager.h new file mode 100644 index 0000000..23d79b5 --- /dev/null +++ b/src/reticulum/ReticulumManager.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "transport/LoRaInterface.h" +#include "storage/FlashStore.h" +#include "storage/SDStore.h" + +class LittleFSFileSystem : public RNS::FileSystemImpl { +public: + virtual bool init() override; + virtual bool file_exists(const char* file_path) override; + virtual size_t read_file(const char* file_path, RNS::Bytes& data) override; + virtual size_t write_file(const char* file_path, const RNS::Bytes& data) override; + virtual RNS::FileStream open_file(const char* file_path, RNS::FileStream::MODE file_mode) override; + virtual bool remove_file(const char* file_path) override; + virtual bool rename_file(const char* from, const char* to) override; + virtual bool directory_exists(const char* directory_path) override; + virtual bool create_directory(const char* directory_path) override; + virtual bool remove_directory(const char* directory_path) override; + virtual std::list list_directory(const char* directory_path) override; + virtual size_t storage_size() override; + virtual size_t storage_available() override; +}; + +class ReticulumManager { +public: + ReticulumManager() + : _reticulum({RNS::Type::NONE}), + _identity({RNS::Type::NONE}), + _destination({RNS::Type::NONE}), + _loraIface({RNS::Type::NONE}) {} + + bool begin(SX1262* radio, FlashStore* flash); + void setSDStore(SDStore* sd) { _sd = sd; } + void loop(); + void persistData(); + + const RNS::Identity& identity() const { return _identity; } + String identityHash() const; + + bool isTransportActive() const { return _transportActive; } + size_t pathCount() const; + size_t linkCount() const; + + void announce(const RNS::Bytes& appData = {}); + unsigned long lastAnnounceTime() const { return _lastAnnounceTime; } + + RNS::Destination& destination() { return _destination; } + LoRaInterface* loraInterface() { return _loraImpl; } + +private: + bool loadOrCreateIdentity(); + void saveIdentityToAll(const RNS::Bytes& keyData); + + RNS::Reticulum _reticulum; + RNS::Identity _identity; + RNS::Destination _destination; + RNS::Interface _loraIface; + LoRaInterface* _loraImpl = nullptr; + FlashStore* _flash = nullptr; + SDStore* _sd = nullptr; + bool _transportActive = false; + unsigned long _lastPersist = 0; + unsigned long _lastAnnounceTime = 0; +}; diff --git a/src/storage/FlashStore.cpp b/src/storage/FlashStore.cpp new file mode 100644 index 0000000..f72bfc8 --- /dev/null +++ b/src/storage/FlashStore.cpp @@ -0,0 +1,128 @@ +#include "FlashStore.h" + +bool FlashStore::begin() { + // Partition label must match partitions_16MB.csv ("littlefs") + // Arduino ESP32 LittleFS defaults to "spiffs" label, so we must specify it + if (!LittleFS.begin(true, "/littlefs", 10, "littlefs")) { + Serial.println("[FLASH] LittleFS mount failed, formatting..."); + LittleFS.format(); + if (!LittleFS.begin(false, "/littlefs", 10, "littlefs")) { + Serial.println("[FLASH] LittleFS failed after format!"); + return false; + } + } + _ready = true; + + ensureDir("/identity"); + ensureDir("/transport"); + ensureDir("/config"); + ensureDir("/contacts"); + ensureDir("/messages"); + + Serial.printf("[FLASH] LittleFS ready, total=%lu, used=%lu\n", + (unsigned long)LittleFS.totalBytes(), + (unsigned long)LittleFS.usedBytes()); + return true; +} + +void FlashStore::end() { + LittleFS.end(); + _ready = false; +} + +bool FlashStore::ensureDir(const char* path) { + if (!_ready) return false; + if (LittleFS.exists(path)) return true; + return LittleFS.mkdir(path); +} + +bool FlashStore::exists(const char* path) { + if (!_ready) return false; + return LittleFS.exists(path); +} + +bool FlashStore::remove(const char* path) { + if (!_ready) return false; + return LittleFS.remove(path); +} + +bool FlashStore::writeAtomic(const char* path, const uint8_t* data, size_t len) { + if (!_ready) return false; + + String tmpPath = String(path) + ".tmp"; + String bakPath = String(path) + ".bak"; + + File f = LittleFS.open(tmpPath.c_str(), "w"); + if (!f) return false; + size_t written = f.write(data, len); + f.close(); + if (written != len) { + LittleFS.remove(tmpPath.c_str()); + return false; + } + + File verify = LittleFS.open(tmpPath.c_str(), "r"); + if (!verify || verify.size() != len) { + if (verify) verify.close(); + LittleFS.remove(tmpPath.c_str()); + return false; + } + verify.close(); + + if (LittleFS.exists(path)) { + LittleFS.remove(bakPath.c_str()); + LittleFS.rename(path, bakPath.c_str()); + } + + if (!LittleFS.rename(tmpPath.c_str(), path)) { + if (LittleFS.exists(bakPath.c_str())) { + LittleFS.rename(bakPath.c_str(), path); + } + return false; + } + + return true; +} + +bool FlashStore::readFile(const char* path, uint8_t* buffer, size_t maxLen, size_t& bytesRead) { + if (!_ready) return false; + + File f = LittleFS.open(path, "r"); + if (!f) { + String bakPath = String(path) + ".bak"; + f = LittleFS.open(bakPath.c_str(), "r"); + if (!f) return false; + Serial.printf("[FLASH] Restored from backup: %s\n", path); + } + + bytesRead = f.readBytes((char*)buffer, maxLen); + f.close(); + return bytesRead > 0; +} + +bool FlashStore::writeString(const char* path, const String& data) { + return writeAtomic(path, (const uint8_t*)data.c_str(), data.length()); +} + +String FlashStore::readString(const char* path) { + if (!_ready) return ""; + File f = LittleFS.open(path, "r"); + if (!f) { + String bakPath = String(path) + ".bak"; + f = LittleFS.open(bakPath.c_str(), "r"); + if (!f) return ""; + } + String result = f.readString(); + f.close(); + return result; +} + +bool FlashStore::format() { + Serial.println("[FLASH] Formatting LittleFS..."); + LittleFS.end(); + bool ok = LittleFS.format(); + if (ok) { + ok = begin(); + } + return ok; +} diff --git a/src/storage/FlashStore.h b/src/storage/FlashStore.h new file mode 100644 index 0000000..b5a67a3 --- /dev/null +++ b/src/storage/FlashStore.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +class FlashStore { +public: + bool begin(); + void end(); + + bool writeAtomic(const char* path, const uint8_t* data, size_t len); + bool readFile(const char* path, uint8_t* buffer, size_t maxLen, size_t& bytesRead); + + bool writeString(const char* path, const String& data); + String readString(const char* path); + + bool ensureDir(const char* path); + bool exists(const char* path); + bool remove(const char* path); + + bool format(); + + bool isReady() const { return _ready; } + +private: + bool _ready = false; +}; diff --git a/src/storage/MessageStore.cpp b/src/storage/MessageStore.cpp new file mode 100644 index 0000000..e461f0d --- /dev/null +++ b/src/storage/MessageStore.cpp @@ -0,0 +1,361 @@ +#include "MessageStore.h" +#include "config/Config.h" +#include +#include + +bool MessageStore::begin(FlashStore* flash, SDStore* sd) { + _flash = flash; + _sd = sd; + _flash->ensureDir(PATH_MESSAGES); + + if (_sd && _sd->isReady()) { + _sd->ensureDir("/ratputer"); + _sd->ensureDir("/ratputer/messages"); + migrateFlashToSD(); + } + + refreshConversations(); + Serial.printf("[MSGSTORE] %d conversations found\n", (int)_conversations.size()); + return true; +} + +void MessageStore::migrateFlashToSD() { + if (!_sd || !_sd->isReady() || !_flash) return; + + File dir = LittleFS.open(PATH_MESSAGES); + if (!dir || !dir.isDirectory()) return; + + int migrated = 0; + File peerDir = dir.openNextFile(); + while (peerDir) { + if (peerDir.isDirectory()) { + std::string peerHex = peerDir.name(); + String sdDir = sdConversationDir(peerHex); + _sd->ensureDir(sdDir.c_str()); + + File entry = peerDir.openNextFile(); + while (entry) { + if (!entry.isDirectory()) { + String sdPath = sdDir + "/" + entry.name(); + if (!_sd->exists(sdPath.c_str())) { + size_t size = entry.size(); + if (size > 0 && size < 4096) { + String json = entry.readString(); + _sd->writeString(sdPath.c_str(), json); + migrated++; + yield(); + } + } + } + entry = peerDir.openNextFile(); + } + enforceFlashLimit(peerHex); + } + peerDir = dir.openNextFile(); + } + + if (migrated > 0) { + Serial.printf("[MSGSTORE] Migrated %d messages from flash to SD\n", migrated); + } +} + +void MessageStore::refreshConversations() { + _conversations.clear(); + + if (_sd && _sd->isReady()) { + File dir = _sd->openDir(SD_PATH_MESSAGES); + if (dir && dir.isDirectory()) { + File entry = dir.openNextFile(); + while (entry) { + if (entry.isDirectory()) { + _conversations.push_back(entry.name()); + } + entry = dir.openNextFile(); + } + } + } + + File dir = LittleFS.open(PATH_MESSAGES); + if (dir && dir.isDirectory()) { + File entry = dir.openNextFile(); + while (entry) { + if (entry.isDirectory()) { + std::string name = entry.name(); + bool found = false; + for (auto& c : _conversations) { + if (c == name) { found = true; break; } + } + if (!found) _conversations.push_back(name); + } + entry = dir.openNextFile(); + } + } +} + +bool MessageStore::saveMessage(const LXMFMessage& msg) { + if (!_flash) return false; + + std::string peerHex = msg.incoming ? + msg.sourceHash.toHex() : msg.destHash.toHex(); + + JsonDocument doc; + doc["src"] = msg.sourceHash.toHex(); + doc["dst"] = msg.destHash.toHex(); + doc["ts"] = msg.timestamp; + doc["content"] = msg.content; + doc["title"] = msg.title; + doc["incoming"] = msg.incoming; + doc["status"] = (int)msg.status; + doc["read"] = msg.incoming ? msg.read : true; + + String json; + serializeJson(doc, json); + + char filename[64]; + snprintf(filename, sizeof(filename), "%lu_%c.json", + (unsigned long)(msg.timestamp * 1000), + msg.incoming ? 'i' : 'o'); + + bool sdOk = false; + bool flashOk = false; + + if (_sd && _sd->isReady()) { + String sdDir = sdConversationDir(peerHex); + _sd->ensureDir(sdDir.c_str()); + String sdPath = sdDir + "/" + filename; + sdOk = _sd->writeString(sdPath.c_str(), json); + } + + String flashDir = conversationDir(peerHex); + _flash->ensureDir(flashDir.c_str()); + String flashPath = flashDir + "/" + filename; + flashOk = _flash->writeString(flashPath.c_str(), json); + + bool found = false; + for (auto& c : _conversations) { + if (c == peerHex) { found = true; break; } + } + if (!found) _conversations.push_back(peerHex); + + if (sdOk) enforceSDLimit(peerHex); + if (flashOk) enforceFlashLimit(peerHex); + + return sdOk || flashOk; +} + +std::vector MessageStore::loadConversation(const std::string& peerHex) const { + std::vector messages; + + auto loadFromDir = [&](File& d) { + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory()) { + size_t size = entry.size(); + if (size > 0 && size < 4096) { + String json = entry.readString(); + JsonDocument doc; + if (!deserializeJson(doc, json)) { + LXMFMessage msg; + std::string srcHex = doc["src"] | ""; + std::string dstHex = doc["dst"] | ""; + if (!srcHex.empty()) { + msg.sourceHash = RNS::Bytes(); + msg.sourceHash.assignHex(srcHex.c_str()); + } + if (!dstHex.empty()) { + msg.destHash = RNS::Bytes(); + msg.destHash.assignHex(dstHex.c_str()); + } + msg.timestamp = doc["ts"] | 0.0; + msg.content = doc["content"] | ""; + msg.title = doc["title"] | ""; + msg.incoming = doc["incoming"] | false; + msg.status = (LXMFStatus)(doc["status"] | 0); + msg.read = doc["read"] | false; + messages.push_back(msg); + } + } + } + entry = d.openNextFile(); + } + }; + + bool loadedFromSD = false; + if (_sd && _sd->isReady()) { + String sdDir = sdConversationDir(peerHex); + File d = _sd->openDir(sdDir.c_str()); + if (d && d.isDirectory()) { + loadFromDir(d); + loadedFromSD = true; + } + } + + if (!loadedFromSD && _flash) { + String dir = conversationDir(peerHex); + File d = LittleFS.open(dir); + if (d && d.isDirectory()) { + loadFromDir(d); + } + } + + std::sort(messages.begin(), messages.end(), + [](const LXMFMessage& a, const LXMFMessage& b) { + return a.timestamp < b.timestamp; + }); + + return messages; +} + +int MessageStore::messageCount(const std::string& peerHex) const { + if (_sd && _sd->isReady()) { + String sdDir = sdConversationDir(peerHex); + File d = _sd->openDir(sdDir.c_str()); + if (d && d.isDirectory()) { + int count = 0; + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory()) count++; + entry = d.openNextFile(); + } + return count; + } + } + String dir = conversationDir(peerHex); + File d = LittleFS.open(dir); + if (!d || !d.isDirectory()) return 0; + int count = 0; + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory()) count++; + entry = d.openNextFile(); + } + return count; +} + +bool MessageStore::deleteConversation(const std::string& peerHex) { + if (_sd && _sd->isReady()) { + String sdDir = sdConversationDir(peerHex); + File d = _sd->openDir(sdDir.c_str()); + if (d && d.isDirectory()) { + File entry = d.openNextFile(); + while (entry) { + String path = sdDir + "/" + entry.name(); + entry.close(); + _sd->remove(path.c_str()); + entry = d.openNextFile(); + } + } + _sd->removeDir(sdDir.c_str()); + } + + String dir = conversationDir(peerHex); + File d = LittleFS.open(dir); + if (d && d.isDirectory()) { + File entry = d.openNextFile(); + while (entry) { + String path = String(dir) + "/" + entry.name(); + entry.close(); + LittleFS.remove(path); + entry = d.openNextFile(); + } + } + LittleFS.rmdir(dir); + + _conversations.erase( + std::remove(_conversations.begin(), _conversations.end(), peerHex), + _conversations.end()); + return true; +} + +void MessageStore::markConversationRead(const std::string& peerHex) { + auto markInDir = [&](auto openFn, auto writeFn, const String& dir) { + File d = openFn(dir.c_str()); + if (!d || !d.isDirectory()) return; + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory()) { + size_t size = entry.size(); + if (size > 0 && size < 4096) { + String json = entry.readString(); + JsonDocument doc; + if (!deserializeJson(doc, json)) { + bool incoming = doc["incoming"] | false; + bool isRead = doc["read"] | false; + if (incoming && !isRead) { + doc["read"] = true; + String updated; + serializeJson(doc, updated); + String path = dir + "/" + entry.name(); + writeFn(path.c_str(), updated); + } + } + } + } + entry = d.openNextFile(); + } + }; + + if (_sd && _sd->isReady()) { + String sdDir = sdConversationDir(peerHex); + markInDir([&](const char* p) { return _sd->openDir(p); }, + [&](const char* p, const String& d) { _sd->writeString(p, d); return true; }, + sdDir); + } + + if (_flash) { + String dir = conversationDir(peerHex); + markInDir([](const char* p) { return LittleFS.open(p); }, + [&](const char* p, const String& d) { _flash->writeString(p, d); return true; }, + dir); + } +} + +String MessageStore::conversationDir(const std::string& peerHex) const { + return String(PATH_MESSAGES) + peerHex.substr(0, 16).c_str(); +} + +String MessageStore::sdConversationDir(const std::string& peerHex) const { + return String(SD_PATH_MESSAGES) + peerHex.substr(0, 16).c_str(); +} + +void MessageStore::enforceFlashLimit(const std::string& peerHex) { + String dir = conversationDir(peerHex); + std::vector files; + File d = LittleFS.open(dir); + if (!d || !d.isDirectory()) return; + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory()) { + files.push_back(String(dir) + "/" + entry.name()); + } + entry = d.openNextFile(); + } + int limit = (_sd && _sd->isReady()) ? FLASH_MSG_CACHE_LIMIT : RATDECK_MAX_MESSAGES_PER_CONV; + if ((int)files.size() <= limit) return; + std::sort(files.begin(), files.end()); + int excess = files.size() - limit; + for (int i = 0; i < excess; i++) { + LittleFS.remove(files[i]); + } +} + +void MessageStore::enforceSDLimit(const std::string& peerHex) { + if (!_sd || !_sd->isReady()) return; + String dir = sdConversationDir(peerHex); + std::vector files; + File d = _sd->openDir(dir.c_str()); + if (!d || !d.isDirectory()) return; + File entry = d.openNextFile(); + while (entry) { + if (!entry.isDirectory()) { + files.push_back(dir + "/" + entry.name()); + } + entry = d.openNextFile(); + } + if ((int)files.size() <= RATDECK_MAX_MESSAGES_PER_CONV) return; + std::sort(files.begin(), files.end()); + int excess = files.size() - RATDECK_MAX_MESSAGES_PER_CONV; + for (int i = 0; i < excess; i++) { + _sd->remove(files[i].c_str()); + } +} diff --git a/src/storage/MessageStore.h b/src/storage/MessageStore.h new file mode 100644 index 0000000..cdb33f6 --- /dev/null +++ b/src/storage/MessageStore.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include "storage/FlashStore.h" +#include "storage/SDStore.h" +#include "reticulum/LXMFMessage.h" +#include +#include +#include + +class MessageStore { +public: + bool begin(FlashStore* flash, SDStore* sd = nullptr); + + bool saveMessage(const LXMFMessage& msg); + std::vector loadConversation(const std::string& peerHex) const; + const std::vector& conversations() const { return _conversations; } + void refreshConversations(); + int messageCount(const std::string& peerHex) const; + bool deleteConversation(const std::string& peerHex); + void markConversationRead(const std::string& peerHex); + +private: + String conversationDir(const std::string& peerHex) const; + String sdConversationDir(const std::string& peerHex) const; + void enforceFlashLimit(const std::string& peerHex); + void enforceSDLimit(const std::string& peerHex); + void migrateFlashToSD(); + + FlashStore* _flash = nullptr; + SDStore* _sd = nullptr; + std::vector _conversations; +}; diff --git a/src/storage/SDStore.cpp b/src/storage/SDStore.cpp new file mode 100644 index 0000000..9903d04 --- /dev/null +++ b/src/storage/SDStore.cpp @@ -0,0 +1,192 @@ +#include "SDStore.h" +#include "config/Config.h" + +bool SDStore::begin(SPIClass* spi, int csPin) { + if (!spi) return false; + + // Deassert CS, then try mounting at conservative 4MHz first + pinMode(csPin, OUTPUT); + digitalWrite(csPin, HIGH); + delay(10); + + if (!SD.begin(csPin, *spi, 4000000)) { + Serial.printf("[SD] Mount failed (CS=%d), retrying...\n", csPin); + delay(100); + // Second attempt + if (!SD.begin(csPin, *spi, 4000000)) { + Serial.println("[SD] Card not detected or mount failed"); + _ready = false; + return false; + } + } + + uint8_t cardType = SD.cardType(); + if (cardType == CARD_NONE) { + Serial.println("[SD] No card inserted"); + _ready = false; + return false; + } + + const char* typeStr = "UNKNOWN"; + if (cardType == CARD_MMC) typeStr = "MMC"; + if (cardType == CARD_SD) typeStr = "SD"; + if (cardType == CARD_SDHC) typeStr = "SDHC"; + + _ready = true; + Serial.printf("[SD] %s card ready, total=%llu MB, used=%llu MB\n", + typeStr, totalBytes() / (1024 * 1024), usedBytes() / (1024 * 1024)); + return true; +} + +void SDStore::end() { SD.end(); _ready = false; } + +uint64_t SDStore::totalBytes() const { return _ready ? SD.totalBytes() : 0; } +uint64_t SDStore::usedBytes() const { return _ready ? SD.usedBytes() : 0; } + +bool SDStore::ensureDir(const char* path) { + if (!_ready) return false; + if (SD.exists(path)) return true; + return SD.mkdir(path); +} + +bool SDStore::exists(const char* path) { return _ready ? SD.exists(path) : false; } +bool SDStore::remove(const char* path) { return _ready ? SD.remove(path) : false; } +File SDStore::openDir(const char* path) { return _ready ? SD.open(path) : File(); } +bool SDStore::removeDir(const char* path) { return _ready ? SD.rmdir(path) : false; } + +bool SDStore::readFile(const char* path, uint8_t* buffer, size_t maxLen, size_t& bytesRead) { + bytesRead = 0; + if (!_ready) return false; + File f = SD.open(path, FILE_READ); + if (!f) return false; + size_t size = f.size(); + if (size > maxLen) { f.close(); return false; } + bytesRead = f.read(buffer, size); + f.close(); + return bytesRead == size; +} + +bool SDStore::writeAtomic(const char* path, const uint8_t* data, size_t len) { + if (!_ready) return false; + + String tmpPath = String(path) + ".tmp"; + String bakPath = String(path) + ".bak"; + + File f = SD.open(tmpPath.c_str(), FILE_WRITE); + if (!f) { + Serial.printf("[SD] writeAtomic: failed to open tmp %s\n", tmpPath.c_str()); + return false; + } + size_t written = f.write(data, len); + f.close(); + if (written != len) { + Serial.printf("[SD] writeAtomic: write incomplete (%d/%d)\n", (int)written, (int)len); + SD.remove(tmpPath.c_str()); + return false; + } + + File verify = SD.open(tmpPath.c_str(), FILE_READ); + if (!verify || verify.size() != len) { + Serial.println("[SD] writeAtomic: verify failed"); + if (verify) verify.close(); + SD.remove(tmpPath.c_str()); + return false; + } + verify.close(); + + if (SD.exists(path)) { + SD.remove(bakPath.c_str()); + SD.rename(path, bakPath.c_str()); + } + + // ESP32 FatFs f_rename() fails with FR_EXIST if destination exists. + // Remove destination first to ensure rename succeeds. + SD.remove(path); + + if (!SD.rename(tmpPath.c_str(), path)) { + Serial.printf("[SD] writeAtomic: rename failed %s -> %s\n", tmpPath.c_str(), path); + if (SD.exists(bakPath.c_str())) { SD.rename(bakPath.c_str(), path); } + return false; + } + return true; +} + +bool SDStore::writeSimple(const char* path, const uint8_t* data, size_t len) { + if (!_ready) return false; + + File f = SD.open(path, FILE_WRITE); + if (!f) { + Serial.printf("[SD] writeSimple: failed to open %s\n", path); + return false; + } + size_t written = f.write(data, len); + f.close(); + if (written != len) { + Serial.printf("[SD] writeSimple: write incomplete (%d/%d)\n", (int)written, (int)len); + return false; + } + return true; +} + +bool SDStore::writeString(const char* path, const String& data) { + if (writeAtomic(path, (const uint8_t*)data.c_str(), data.length())) { + return true; + } + Serial.println("[SD] writeAtomic failed, trying writeSimple fallback"); + return writeSimple(path, (const uint8_t*)data.c_str(), data.length()); +} + +String SDStore::readString(const char* path) { + if (!_ready) return ""; + File f = SD.open(path, FILE_READ); + if (!f) { + String bakPath = String(path) + ".bak"; + f = SD.open(bakPath.c_str(), FILE_READ); + if (!f) return ""; + } + String result = f.readString(); + f.close(); + return result; +} + +bool SDStore::wipeRatputer() { + if (!_ready) return false; + Serial.println("[SD] Wiping /ratputer/ ..."); + wipeDir("/ratputer/messages"); + wipeDir("/ratputer/contacts"); + wipeDir("/ratputer/identity"); + wipeDir("/ratputer/config"); + SD.rmdir("/ratputer"); + Serial.println("[SD] Wipe complete, recreating dirs..."); + return formatForRatputer(); +} + +void SDStore::wipeDir(const char* path) { + File dir = SD.open(path); + if (!dir || !dir.isDirectory()) return; + File entry = dir.openNextFile(); + while (entry) { + String fullPath = String(path) + "/" + entry.name(); + if (entry.isDirectory()) { + wipeDir(fullPath.c_str()); + SD.rmdir(fullPath.c_str()); + } else { + SD.remove(fullPath.c_str()); + } + entry = dir.openNextFile(); + } + dir.close(); +} + +bool SDStore::formatForRatputer() { + if (!_ready) return false; + Serial.println("[SD] Creating Ratputer directory structure..."); + bool ok = true; + ok &= ensureDir("/ratputer"); + ok &= ensureDir("/ratputer/config"); + ok &= ensureDir("/ratputer/messages"); + ok &= ensureDir("/ratputer/contacts"); + ok &= ensureDir("/ratputer/identity"); + if (ok) Serial.println("[SD] Directory structure ready"); + return ok; +} diff --git a/src/storage/SDStore.h b/src/storage/SDStore.h new file mode 100644 index 0000000..add4d3f --- /dev/null +++ b/src/storage/SDStore.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +class SDStore { +public: + bool begin(SPIClass* spi, int csPin); + void end(); + + bool writeAtomic(const char* path, const uint8_t* data, size_t len); + bool writeSimple(const char* path, const uint8_t* data, size_t len); + bool writeString(const char* path, const String& data); + String readString(const char* path); + + bool ensureDir(const char* path); + bool exists(const char* path); + bool remove(const char* path); + File openDir(const char* path); + bool removeDir(const char* path); + bool readFile(const char* path, uint8_t* buffer, size_t maxLen, size_t& bytesRead); + + bool isReady() const { return _ready; } + uint64_t totalBytes() const; + uint64_t usedBytes() const; + + bool formatForRatputer(); + bool wipeRatputer(); + +private: + void wipeDir(const char* path); + bool _ready = false; +}; diff --git a/src/transport/BLEInterface.cpp b/src/transport/BLEInterface.cpp new file mode 100644 index 0000000..773c68d --- /dev/null +++ b/src/transport/BLEInterface.cpp @@ -0,0 +1,164 @@ +#include "BLEInterface.h" +#include "BLESideband.h" + +BLEInterface::BLEInterface(const char* name) + : RNS::InterfaceImpl(name) +{ + _IN = true; + _OUT = true; + _bitrate = 100000; + _HW_MTU = 185; +} + +BLEInterface::~BLEInterface() { + stop(); +} + +bool BLEInterface::start() { + NimBLEDevice::init("Ratdeck"); + NimBLEDevice::setMTU(512); + + _pServer = NimBLEDevice::createServer(); + _pServer->setCallbacks(this, false); + + _pService = _pServer->createService(SERVICE_UUID); + + // TX: Ratdeck -> remote (NOTIFY) + _pTxChar = _pService->createCharacteristic( + TX_CHAR_UUID, + NIMBLE_PROPERTY::NOTIFY + ); + + // RX: remote -> Ratdeck (WRITE) + _pRxChar = _pService->createCharacteristic( + RX_CHAR_UUID, + NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR + ); + _pRxChar->setCallbacks(this); + + _pService->start(); + _pServer->start(); + + NimBLEAdvertising* pAdv = NimBLEDevice::getAdvertising(); + pAdv->addServiceUUID(SERVICE_UUID); + pAdv->setName("Ratdeck"); + pAdv->start(); + + _active = true; + _online = true; + Serial.println("[BLE] Transport started, advertising"); + return true; +} + +void BLEInterface::stop() { + if (_active) { + NimBLEDevice::deinit(true); + _active = false; + _online = false; + _connected = false; + Serial.println("[BLE] Transport stopped"); + } +} + +void BLEInterface::onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) { + _connected = true; + Serial.printf("[BLE] Client connected: %s\n", connInfo.getAddress().toString().c_str()); + if (_sideband) _sideband->notifyConnect(); +} + +void BLEInterface::onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) { + _connected = false; + Serial.printf("[BLE] Client disconnected (reason=%d)\n", reason); + if (_sideband) _sideband->notifyDisconnect(); + // Restart advertising + NimBLEDevice::getAdvertising()->start(); +} + +void BLEInterface::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) { + NimBLEAttValue val = pCharacteristic->getValue(); + const uint8_t* data = val.data(); + size_t len = val.size(); + + for (size_t i = 0; i < len; i++) { + processRxByte(data[i]); + } +} + +void BLEInterface::processRxByte(uint8_t b) { + if (b == FRAME_START) { + if (_rxActive && !_rxFrame.empty()) { + // Complete frame received — queue it + _incomingFrames.push_back(std::move(_rxFrame)); + _rxFrame.clear(); + } + _rxActive = true; + _rxEscape = false; + _rxFrame.clear(); + return; + } + if (!_rxActive) return; + + if (b == FRAME_ESC) { + _rxEscape = true; + return; + } + if (_rxEscape) { + b ^= FRAME_XOR; + _rxEscape = false; + } + if (_rxFrame.size() < 600) { + _rxFrame.push_back(b); + } +} + +void BLEInterface::loop() { + if (!_active) return; + + // Process queued incoming frames + while (!_incomingFrames.empty()) { + auto frame = std::move(_incomingFrames.front()); + _incomingFrames.erase(_incomingFrames.begin()); + + if (!frame.empty()) { + RNS::Bytes data(frame.data(), frame.size()); + handle_incoming(data); + } + } +} + +void BLEInterface::injectIncoming(const RNS::Bytes& data) { + handle_incoming(data); +} + +void BLEInterface::send_outgoing(const RNS::Bytes& data) { + if (!_connected || !_pTxChar) return; + sendFrame(data.data(), data.size()); +} + +void BLEInterface::sendFrame(const uint8_t* data, size_t len) { + // HDLC-frame the data and send via BLE notify + // BLE MTU limits each notify, so we may need to chunk + std::vector frame; + frame.reserve(len * 2 + 2); + frame.push_back(FRAME_START); + + for (size_t i = 0; i < len; i++) { + uint8_t b = data[i]; + if (b == FRAME_START || b == FRAME_ESC) { + frame.push_back(FRAME_ESC); + frame.push_back(b ^ FRAME_XOR); + } else { + frame.push_back(b); + } + } + frame.push_back(FRAME_START); + + // Send in MTU-sized chunks + uint16_t mtu = NimBLEDevice::getMTU() - 3; // ATT overhead + if (mtu < 20) mtu = 20; + + for (size_t offset = 0; offset < frame.size(); offset += mtu) { + size_t chunk = std::min((size_t)mtu, frame.size() - offset); + _pTxChar->notify(frame.data() + offset, chunk); + } +} diff --git a/src/transport/BLEInterface.h b/src/transport/BLEInterface.h new file mode 100644 index 0000000..5746914 --- /dev/null +++ b/src/transport/BLEInterface.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include + +// BLE transport interface for Reticulum packet relay +// NimBLE GATT server with RX/TX characteristics, HDLC framing +class BLEInterface : public RNS::InterfaceImpl, + public NimBLEServerCallbacks, + public NimBLECharacteristicCallbacks { +public: + BLEInterface(const char* name = "BLEInterface"); + virtual ~BLEInterface(); + + bool start() override; + void stop() override; + void loop() override; + + virtual inline std::string toString() const override { + return "BLEInterface[" + _name + "]"; + } + + bool isActive() const { return _active; } + bool isClientConnected() const { return _connected; } + + // Get server for sharing with BLESideband (only valid after start()) + NimBLEServer* getServer() { return _pServer; } + + // Set companion Sideband service for connection event forwarding + void setSideband(class BLESideband* sb) { _sideband = sb; } + + // Inject a packet from external source (e.g. Sideband) into Reticulum + void injectIncoming(const RNS::Bytes& data); + + // NimBLE server callbacks + void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) override; + void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) override; + + // NimBLE characteristic callbacks + void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override; + +protected: + void send_outgoing(const RNS::Bytes& data) override; + +private: + // HDLC framing (same as WiFi/TCP interfaces) + void sendFrame(const uint8_t* data, size_t len); + void processRxByte(uint8_t b); + + NimBLEServer* _pServer = nullptr; + NimBLEService* _pService = nullptr; + NimBLECharacteristic* _pTxChar = nullptr; + NimBLECharacteristic* _pRxChar = nullptr; + + bool _active = false; + bool _connected = false; + + // HDLC rx state + std::vector _rxFrame; + bool _rxEscape = false; + bool _rxActive = false; + + // Queued incoming frames (written by BLE callback, consumed by loop) + std::vector> _incomingFrames; + + class BLESideband* _sideband = nullptr; + + static constexpr uint8_t FRAME_START = 0x7E; + static constexpr uint8_t FRAME_ESC = 0x7D; + static constexpr uint8_t FRAME_XOR = 0x20; + + // Custom UUIDs for Ratdeck BLE transport + static constexpr const char* SERVICE_UUID = "e2f0a5b1-c3d4-4e56-8f90-1a2b3c4d5e6f"; + static constexpr const char* TX_CHAR_UUID = "e2f0a5b2-c3d4-4e56-8f90-1a2b3c4d5e6f"; + static constexpr const char* RX_CHAR_UUID = "e2f0a5b3-c3d4-4e56-8f90-1a2b3c4d5e6f"; +}; diff --git a/src/transport/BLESideband.cpp b/src/transport/BLESideband.cpp new file mode 100644 index 0000000..118faad --- /dev/null +++ b/src/transport/BLESideband.cpp @@ -0,0 +1,148 @@ +#include "BLESideband.h" + +bool BLESideband::begin(NimBLEServer* existingServer) { + if (existingServer) { + _pServer = existingServer; + _ownServer = false; + } else { + NimBLEDevice::init("Ratdeck"); + _pServer = NimBLEDevice::createServer(); + _ownServer = true; + } + + // Create Nordic UART Service (don't set server callbacks — BLEInterface owns them) + _pService = _pServer->createService(NUS_SERVICE_UUID); + + // TX: Ratdeck -> Sideband (NOTIFY) + _pTxChar = _pService->createCharacteristic( + NUS_TX_UUID, + NIMBLE_PROPERTY::NOTIFY + ); + + // RX: Sideband -> Ratdeck (WRITE) + _pRxChar = _pService->createCharacteristic( + NUS_RX_UUID, + NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR + ); + _pRxChar->setCallbacks(this); + + _pService->start(); + + // Only start server + advertising if we own it + if (_ownServer) { + _pServer->start(); + NimBLEAdvertising* pAdv = NimBLEDevice::getAdvertising(); + pAdv->addServiceUUID(NUS_SERVICE_UUID); + pAdv->setName("Ratdeck"); + pAdv->start(); + } else { + // Add NUS UUID to existing advertising + NimBLEAdvertising* pAdv = NimBLEDevice::getAdvertising(); + pAdv->addServiceUUID(NUS_SERVICE_UUID); + } + + Serial.println("[BLE] Sideband NUS service started"); + return true; +} + +void BLESideband::stop() { + _connected = false; + if (_ownServer) { + NimBLEDevice::deinit(true); + } +} + +void BLESideband::notifyConnect() { + _connected = true; +} + +void BLESideband::notifyDisconnect() { + _connected = false; +} + +void BLESideband::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) { + NimBLEAttValue val = pCharacteristic->getValue(); + const uint8_t* data = val.data(); + size_t len = val.size(); + + for (size_t i = 0; i < len; i++) { + processRxByte(data[i]); + } +} + +void BLESideband::processRxByte(uint8_t b) { + if (b == KISS_FEND) { + if (_rxInFrame && !_rxFrame.empty()) { + // First byte is KISS command; 0x00 = data frame + if (_rxFrame[0] == KISS_CMD_DATA && _rxFrame.size() > 1) { + std::vector pkt(_rxFrame.begin() + 1, _rxFrame.end()); + _incomingPackets.push_back(std::move(pkt)); + } + _rxFrame.clear(); + } + _rxInFrame = true; + _rxFrame.clear(); + return; + } + + if (!_rxInFrame) return; + + // Handle KISS escape sequences + if (b == KISS_FESC) { + _rxEscaped = true; + return; + } + if (_rxEscaped) { + _rxEscaped = false; + if (b == KISS_TFEND) b = KISS_FEND; + else if (b == KISS_TFESC) b = KISS_FESC; + } + + if (_rxFrame.size() < 600) { + _rxFrame.push_back(b); + } +} + +void BLESideband::loop() { + while (!_incomingPackets.empty()) { + auto pkt = std::move(_incomingPackets.front()); + _incomingPackets.erase(_incomingPackets.begin()); + + if (_packetCb && !pkt.empty()) { + _packetCb(pkt.data(), pkt.size()); + } + } +} + +void BLESideband::sendPacket(const uint8_t* data, size_t len) { + if (!_connected || !_pTxChar) return; + + // KISS-frame: FEND + CMD_DATA + escaped_data + FEND + std::vector frame; + frame.reserve(len * 2 + 3); + frame.push_back(KISS_FEND); + frame.push_back(KISS_CMD_DATA); + + for (size_t i = 0; i < len; i++) { + uint8_t b = data[i]; + if (b == KISS_FEND) { + frame.push_back(KISS_FESC); + frame.push_back(KISS_TFEND); + } else if (b == KISS_FESC) { + frame.push_back(KISS_FESC); + frame.push_back(KISS_TFESC); + } else { + frame.push_back(b); + } + } + frame.push_back(KISS_FEND); + + // Send in MTU-sized chunks + uint16_t mtu = NimBLEDevice::getMTU() - 3; + if (mtu < 20) mtu = 20; + + for (size_t offset = 0; offset < frame.size(); offset += mtu) { + size_t chunk = std::min((size_t)mtu, frame.size() - offset); + _pTxChar->notify(frame.data() + offset, chunk); + } +} diff --git a/src/transport/BLESideband.h b/src/transport/BLESideband.h new file mode 100644 index 0000000..a9c1475 --- /dev/null +++ b/src/transport/BLESideband.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include + +// Sideband-compatible BLE GATT service +// Implements KISS-framed serial interface over BLE for Sideband app pairing. +// The Sideband app expects an RNode-compatible BLE serial profile using +// Nordic UART Service (NUS) UUIDs. +class BLESideband : public NimBLECharacteristicCallbacks { +public: + using PacketCallback = std::function; + + bool begin(NimBLEServer* existingServer = nullptr); + void loop(); + void stop(); + + bool isConnected() const { return _connected; } + + // Send a packet (will be KISS-framed) + void sendPacket(const uint8_t* data, size_t len); + + // Callback for received packets (after KISS deframing) + void setPacketCallback(PacketCallback cb) { _packetCb = cb; } + + // Connection events (called by BLEInterface which owns server callbacks) + void notifyConnect(); + void notifyDisconnect(); + + // NimBLE characteristic callback + void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) override; + +private: + void processRxByte(uint8_t b); + + NimBLEServer* _pServer = nullptr; + NimBLEService* _pService = nullptr; + NimBLECharacteristic* _pTxChar = nullptr; + NimBLECharacteristic* _pRxChar = nullptr; + bool _ownServer = false; + bool _connected = false; + + // KISS framing + std::vector _rxFrame; + bool _rxInFrame = false; + bool _rxEscaped = false; + + // Queued incoming packets + std::vector> _incomingPackets; + PacketCallback _packetCb; + + // KISS constants + static constexpr uint8_t KISS_FEND = 0xC0; + static constexpr uint8_t KISS_FESC = 0xDB; + static constexpr uint8_t KISS_TFEND = 0xDC; + static constexpr uint8_t KISS_TFESC = 0xDD; + static constexpr uint8_t KISS_CMD_DATA = 0x00; + + // Nordic UART Service UUIDs (compatible with Sideband/RNode BLE) + static constexpr const char* NUS_SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; + static constexpr const char* NUS_RX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; + static constexpr const char* NUS_TX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; +}; diff --git a/src/transport/LoRaInterface.cpp b/src/transport/LoRaInterface.cpp new file mode 100644 index 0000000..1ca105d --- /dev/null +++ b/src/transport/LoRaInterface.cpp @@ -0,0 +1,119 @@ +#include "LoRaInterface.h" +#include "config/BoardConfig.h" + +// RNode on-air framing constants (from RNode_Firmware_CE Framing.h / Config.h) +// Every LoRa packet has a 1-byte header: upper nibble = random sequence, lower nibble = flags +#define RNODE_HEADER_L 1 +#define RNODE_FLAG_SPLIT 0x01 +#define RNODE_NIBBLE_SEQ 0xF0 + +LoRaInterface::LoRaInterface(SX1262* radio, const char* name) + : RNS::InterfaceImpl(name), _radio(radio) +{ + _IN = true; + _OUT = true; + _bitrate = 2000; // Approximate for SF8/125kHz + _HW_MTU = MAX_PACKET_SIZE - RNODE_HEADER_L; // 254 bytes payload (1 byte reserved for RNode header) +} + +LoRaInterface::~LoRaInterface() { + stop(); +} + +bool LoRaInterface::start() { + if (!_radio || !_radio->isRadioOnline()) { + Serial.println("[LORA_IF] Radio not available"); + _online = false; + return false; + } + _online = true; + _radio->receive(); + Serial.println("[LORA_IF] Interface started"); + return true; +} + +void LoRaInterface::stop() { + _online = false; + Serial.println("[LORA_IF] Interface stopped"); +} + +void LoRaInterface::send_outgoing(const RNS::Bytes& data) { + if (!_online || !_radio) return; + + // Build RNode-compatible 1-byte header: + // Upper nibble: random sequence number (for split-packet tracking) + // Lower nibble: flags (FLAG_SPLIT=0x01 if packet won't fit in single frame) + uint8_t header = (uint8_t)(random(256)) & RNODE_NIBBLE_SEQ; // Random upper nibble, flags=0 + + Serial.printf("[LORA_IF] TX: sending %d bytes, radio: SF%d BW%lu CR%d preamble=%ld freq=%lu txp=%d\n", + data.size(), + _radio->getSpreadingFactor(), + (unsigned long)_radio->getSignalBandwidth(), + _radio->getCodingRate4(), + _radio->getPreambleLength(), + (unsigned long)_radio->getFrequency(), + _radio->getTxPower()); + + _radio->beginPacket(); + _radio->write(header); // 1-byte RNode header + _radio->write(data.data(), data.size()); // Reticulum packet payload + bool sent = _radio->endPacket(); + + if (sent) { + Serial.printf("[LORA_IF] TX %d+1 bytes (hdr=0x%02X)\n", data.size(), header); + InterfaceImpl::handle_outgoing(data); + } else { + Serial.println("[LORA_IF] TX failed (timeout)"); + } + + // Return to RX mode + _radio->receive(); +} + +void LoRaInterface::loop() { + if (!_online || !_radio) return; + + // Periodic RX debug: dump RSSI + IRQ flags + chip status every 5 seconds + static unsigned long lastRxDebug = 0; + if (millis() - lastRxDebug > 5000) { + lastRxDebug = millis(); + int rssi = _radio->currentRssi(); + uint16_t irq = _radio->getIrqFlags(); + uint8_t status = _radio->getStatus(); + uint8_t chipMode = (status >> 4) & 0x07; + Serial.printf("[LORA_IF] RX monitor: RSSI=%d dBm, IRQ=0x%04X, status=0x%02X(mode=%d), devErr=0x%04X\n", + rssi, irq, status, chipMode, _radio->getDeviceErrors()); + } + + int packetSize = _radio->parsePacket(); + if (packetSize > RNODE_HEADER_L) { + // parsePacket() already read the FIFO into packetBuffer() — copy from there + // (avoid calling readBytes() which would re-read the FIFO via read()) + uint8_t raw[MAX_PACKET_SIZE]; + memcpy(raw, _radio->packetBuffer(), packetSize); + + // Strip the 1-byte RNode header, pass only the Reticulum payload + uint8_t header = raw[0]; + int payloadSize = packetSize - RNODE_HEADER_L; + + Serial.printf("[LORA_IF] RX %d bytes (hdr=0x%02X, payload=%d), RSSI=%d, SNR=%.1f\n", + packetSize, header, payloadSize, + _radio->packetRssi(), _radio->packetSnr()); + + // Hex dump first 32 bytes for debugging interop + Serial.printf("[LORA_IF] RX hex: "); + for (int i = 0; i < packetSize && i < 32; i++) Serial.printf("%02X ", raw[i]); + Serial.println(); + + RNS::Bytes buf(payloadSize); + memcpy(buf.writable(payloadSize), raw + RNODE_HEADER_L, payloadSize); + InterfaceImpl::handle_incoming(buf); + + // Re-enter RX + _radio->receive(); + } else if (packetSize > 0) { + // Packet too small (only header, no payload) — discard + Serial.printf("[LORA_IF] RX runt packet (%d bytes), discarding\n", packetSize); + _radio->receive(); + } +} diff --git a/src/transport/LoRaInterface.h b/src/transport/LoRaInterface.h new file mode 100644 index 0000000..87efe42 --- /dev/null +++ b/src/transport/LoRaInterface.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include "radio/SX1262.h" + +class LoRaInterface : public RNS::InterfaceImpl { +public: + LoRaInterface(SX1262* radio, const char* name = "LoRaInterface"); + virtual ~LoRaInterface(); + + virtual bool start() override; + virtual void stop() override; + virtual void loop() override; + + virtual inline std::string toString() const override { + return "LoRaInterface[" + _name + "]"; + } + +protected: + virtual void send_outgoing(const RNS::Bytes& data) override; + +private: + SX1262* _radio; +}; diff --git a/src/transport/TCPClientInterface.cpp b/src/transport/TCPClientInterface.cpp new file mode 100644 index 0000000..f333d5a --- /dev/null +++ b/src/transport/TCPClientInterface.cpp @@ -0,0 +1,125 @@ +#include "TCPClientInterface.h" +#include "config/Config.h" + +TCPClientInterface::TCPClientInterface(const char* host, uint16_t port, const char* name) + : RNS::InterfaceImpl(name), _host(host), _port(port) +{ + _IN = true; + _OUT = true; + _bitrate = 1000000; + _HW_MTU = 500; +} + +TCPClientInterface::~TCPClientInterface() { + stop(); +} + +bool TCPClientInterface::start() { + _online = true; + tryConnect(); + return true; +} + +void TCPClientInterface::stop() { + _online = false; + if (_client.connected()) { + _client.stop(); + Serial.printf("[TCP] Disconnected from %s:%d\n", _host.c_str(), _port); + } +} + +void TCPClientInterface::tryConnect() { + _lastAttempt = millis(); + Serial.printf("[TCP] Connecting to %s:%d...\n", _host.c_str(), _port); + + if (_client.connect(_host.c_str(), _port, TCP_CONNECT_TIMEOUT_MS)) { + Serial.printf("[TCP] Connected to %s:%d\n", _host.c_str(), _port); + } else { + Serial.printf("[TCP] Failed to connect to %s:%d\n", _host.c_str(), _port); + } +} + +void TCPClientInterface::loop() { + if (!_online) return; + + // Auto-reconnect + if (!_client.connected()) { + if (millis() - _lastAttempt >= TCP_RECONNECT_INTERVAL_MS) { + tryConnect(); + } + return; + } + + // Read incoming frames + unsigned long rxStart = millis(); + int len = readFrame(_rxBuffer, sizeof(_rxBuffer)); + if (len > 0) { + RNS::Bytes data(_rxBuffer, len); + Serial.printf("[TCP] RX %d bytes from %s:%d (%lums)\n", + len, _host.c_str(), _port, millis() - rxStart); + InterfaceImpl::handle_incoming(data); + } +} + +void TCPClientInterface::send_outgoing(const RNS::Bytes& data) { + if (!_online || !_client.connected()) return; + + sendFrame(data.data(), data.size()); + Serial.printf("[TCP] TX %d bytes to %s:%d\n", (int)data.size(), _host.c_str(), _port); + InterfaceImpl::handle_outgoing(data); +} + +// HDLC-like framing: [0x7E] [escaped data] [0x7E] +void TCPClientInterface::sendFrame(const uint8_t* data, size_t len) { + _client.write(FRAME_START); + for (size_t i = 0; i < len; i++) { + if (data[i] == FRAME_START || data[i] == FRAME_ESC) { + _client.write(FRAME_ESC); + _client.write(data[i] ^ FRAME_XOR); + } else { + _client.write(data[i]); + } + } + _client.write(FRAME_START); + _client.flush(); +} + +int TCPClientInterface::readFrame(uint8_t* buffer, size_t maxLen) { + if (!_client.available()) return 0; + + bool inFrame = false; + bool escaped = false; + size_t pos = 0; + int bytesRead = 0; + constexpr int MAX_BYTES_PER_CALL = 512; + + while (_client.available() && pos < maxLen && bytesRead < MAX_BYTES_PER_CALL) { + uint8_t b = _client.read(); + bytesRead++; + + if (b == FRAME_START) { + if (inFrame && pos > 0) { + return pos; // End of frame + } + inFrame = true; + pos = 0; + continue; + } + + if (!inFrame) continue; + + if (b == FRAME_ESC) { + escaped = true; + continue; + } + + if (escaped) { + buffer[pos++] = b ^ FRAME_XOR; + escaped = false; + } else { + buffer[pos++] = b; + } + } + + return 0; // Incomplete frame +} diff --git a/src/transport/TCPClientInterface.h b/src/transport/TCPClientInterface.h new file mode 100644 index 0000000..4ce3a58 --- /dev/null +++ b/src/transport/TCPClientInterface.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +class TCPClientInterface : public RNS::InterfaceImpl { +public: + TCPClientInterface(const char* host, uint16_t port, const char* name); + virtual ~TCPClientInterface(); + + bool start() override; + void stop() override; + void loop() override; + + virtual inline std::string toString() const override { + return "TCPClient[" + _name + "]"; + } + + bool isConnected() { return _client.connected(); } + const String& host() const { return _host; } + uint16_t port() const { return _port; } + +protected: + void send_outgoing(const RNS::Bytes& data) override; + +private: + void tryConnect(); + void sendFrame(const uint8_t* data, size_t len); + int readFrame(uint8_t* buffer, size_t maxLen); + + WiFiClient _client; + String _host; + uint16_t _port; + unsigned long _lastAttempt = 0; + uint8_t _rxBuffer[600]; + + static constexpr uint8_t FRAME_START = 0x7E; + static constexpr uint8_t FRAME_ESC = 0x7D; + static constexpr uint8_t FRAME_XOR = 0x20; +}; diff --git a/src/transport/WiFiInterface.cpp b/src/transport/WiFiInterface.cpp new file mode 100644 index 0000000..18a3177 --- /dev/null +++ b/src/transport/WiFiInterface.cpp @@ -0,0 +1,177 @@ +#include "WiFiInterface.h" +#include "config/Config.h" + +WiFiInterface::WiFiInterface(const char* name) + : RNS::InterfaceImpl(name), _server(WIFI_AP_PORT) +{ + _IN = true; + _OUT = true; + _bitrate = 1000000; // WiFi is fast + _HW_MTU = 500; + _apPassword = WIFI_AP_PASSWORD; +} + +WiFiInterface::~WiFiInterface() { + stop(); +} + +void WiFiInterface::setAPCredentials(const char* ssid, const char* password) { + _apSSID = ssid; + _apPassword = password; +} + +void WiFiInterface::setSTACredentials(const char* ssid, const char* password) { + _staSSID = ssid; + _staPassword = password; +} + +bool WiFiInterface::isSTAConnected() const { + return WiFi.status() == WL_CONNECTED; +} + +void WiFiInterface::startAP() { + // Generate SSID from chip ID if not set + if (_apSSID.isEmpty()) { + uint32_t chip = ESP.getEfuseMac() & 0xFFFF; + char ssid[32]; + snprintf(ssid, sizeof(ssid), "ratdeck-%04x", chip); + _apSSID = ssid; + } + + // AP-only mode — saves ~20KB vs WIFI_AP_STA + WiFi.mode(WIFI_AP); + WiFi.softAP(_apSSID.c_str(), _apPassword.c_str()); + + Serial.printf("[WIFI] AP started: %s @ %s\n", + _apSSID.c_str(), + WiFi.softAPIP().toString().c_str()); + + _server.begin(); + _apActive = true; +} + +bool WiFiInterface::start() { + startAP(); + _online = true; + return true; +} + +void WiFiInterface::stop() { + _online = false; + _apActive = false; + for (auto& client : _clients) { + client.stop(); + } + _clients.clear(); + _server.stop(); + WiFi.softAPdisconnect(true); +} + +void WiFiInterface::stopFull() { + stop(); + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + Serial.println("[WIFI] Full shutdown"); +} + +void WiFiInterface::acceptClients() { + WiFiClient newClient = _server.available(); + if (newClient) { + _clients.push_back(newClient); + Serial.printf("[WIFI] Client connected (%d total)\n", (int)_clients.size()); + } +} + +void WiFiInterface::readClients() { + for (int i = _clients.size() - 1; i >= 0; i--) { + if (!_clients[i].connected()) { + _clients[i].stop(); + _clients.erase(_clients.begin() + i); + Serial.printf("[WIFI] Client disconnected (%d total)\n", (int)_clients.size()); + continue; + } + + int len = readFrame(_clients[i], _rxBuffer, sizeof(_rxBuffer)); + if (len > 0) { + RNS::Bytes data(_rxBuffer, len); + Serial.printf("[WIFI] RX %d bytes from client\n", len); + InterfaceImpl::handle_incoming(data); + } + } +} + +void WiFiInterface::sendToClients(const uint8_t* data, size_t len) { + for (auto& client : _clients) { + if (client.connected()) { + sendFrame(client, data, len); + } + } +} + +void WiFiInterface::send_outgoing(const RNS::Bytes& data) { + if (!_online) return; + + sendToClients(data.data(), data.size()); + Serial.printf("[WIFI] TX %d bytes to %d clients\n", + (int)data.size(), (int)_clients.size()); + InterfaceImpl::handle_outgoing(data); +} + +void WiFiInterface::loop() { + if (!_online) return; + acceptClients(); + readClients(); +} + +// HDLC-like framing: [0x7E] [escaped data] [0x7E] +void WiFiInterface::sendFrame(WiFiClient& client, const uint8_t* data, size_t len) { + client.write(FRAME_START); + for (size_t i = 0; i < len; i++) { + if (data[i] == FRAME_START || data[i] == FRAME_ESC) { + client.write(FRAME_ESC); + client.write(data[i] ^ FRAME_XOR); + } else { + client.write(data[i]); + } + } + client.write(FRAME_START); + client.flush(); +} + +int WiFiInterface::readFrame(WiFiClient& client, uint8_t* buffer, size_t maxLen) { + if (!client.available()) return 0; + + // Look for frame start + bool inFrame = false; + bool escaped = false; + size_t pos = 0; + + while (client.available() && pos < maxLen) { + uint8_t b = client.read(); + + if (b == FRAME_START) { + if (inFrame && pos > 0) { + return pos; // End of frame + } + inFrame = true; + pos = 0; + continue; + } + + if (!inFrame) continue; + + if (b == FRAME_ESC) { + escaped = true; + continue; + } + + if (escaped) { + buffer[pos++] = b ^ FRAME_XOR; + escaped = false; + } else { + buffer[pos++] = b; + } + } + + return 0; // Incomplete frame +} diff --git a/src/transport/WiFiInterface.h b/src/transport/WiFiInterface.h new file mode 100644 index 0000000..626cd8b --- /dev/null +++ b/src/transport/WiFiInterface.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include + +class WiFiInterface : public RNS::InterfaceImpl { +public: + WiFiInterface(const char* name = "WiFiInterface"); + virtual ~WiFiInterface(); + + virtual bool start() override; + virtual void stop() override; + virtual void loop() override; + + virtual inline std::string toString() const override { + return "WiFiInterface[" + _name + "]"; + } + + // Full WiFi shutdown (AP + STA + radio off) + void stopFull(); + + // AP config + void setAPCredentials(const char* ssid, const char* password); + String getAPSSID() const { return _apSSID; } + int getClientCount() const { return _clients.size(); } + bool isAPActive() const { return _apActive; } + + // STA config (optional) + void setSTACredentials(const char* ssid, const char* password); + bool isSTAConnected() const; + +protected: + virtual void send_outgoing(const RNS::Bytes& data) override; + +private: + void startAP(); + void acceptClients(); + void readClients(); + void sendToClients(const uint8_t* data, size_t len); + + // HDLC-like framing for TCP stream + void sendFrame(WiFiClient& client, const uint8_t* data, size_t len); + int readFrame(WiFiClient& client, uint8_t* buffer, size_t maxLen); + + String _apSSID; + String _apPassword; + String _staSSID; + String _staPassword; + bool _apActive = false; + + WiFiServer _server; + std::vector _clients; + uint8_t _rxBuffer[600]; + + static constexpr uint8_t FRAME_START = 0x7E; + static constexpr uint8_t FRAME_ESC = 0x7D; + static constexpr uint8_t FRAME_XOR = 0x20; +}; diff --git a/src/ui/StatusBar.cpp b/src/ui/StatusBar.cpp new file mode 100644 index 0000000..f522856 --- /dev/null +++ b/src/ui/StatusBar.cpp @@ -0,0 +1,105 @@ +#include "StatusBar.h" +#include "Theme.h" +#include "hal/Display.h" +#include + +void StatusBar::update() { + bool wasFlashing = (_announceFlashEnd > 0); + if (wasFlashing && millis() >= _announceFlashEnd) { + _announceFlashEnd = 0; + markDirty(); + } + if (_toastEnd > 0 && millis() >= _toastEnd) { + _toastEnd = 0; + _toastMsg.clear(); + markDirty(); + } +} + +void StatusBar::draw(LGFX_TDeck& gfx) { + // Toast notification replaces status bar temporarily + if (_toastEnd > 0 && millis() < _toastEnd) { + gfx.fillRect(0, 0, Theme::SCREEN_W, Theme::STATUS_BAR_H, Theme::ACCENT); + gfx.setTextSize(1); + gfx.setTextColor(Theme::BG, Theme::ACCENT); + // Center the toast text + int textW = (int)_toastMsg.length() * 6; + int tx = (Theme::SCREEN_W - textW) / 2; + gfx.setCursor(tx, 3); + gfx.print(_toastMsg.c_str()); + gfx.drawFastHLine(0, Theme::STATUS_BAR_H - 1, Theme::SCREEN_W, Theme::BORDER); + return; + } + + // Clear status bar area + gfx.fillRect(0, 0, Theme::SCREEN_W, Theme::STATUS_BAR_H, Theme::BG); + gfx.setTextSize(1); + + int x = 2; + + // LoRa indicator + bool flashing = _announceFlashEnd > 0 && millis() < _announceFlashEnd; + if (_loraOnline) { + gfx.setTextColor(flashing ? Theme::ACCENT : Theme::PRIMARY, Theme::BG); + gfx.setCursor(x, 3); + gfx.print(flashing ? "LoRa TX" : "LoRa OK"); + } else { + gfx.setTextColor(Theme::MUTED, Theme::BG); + gfx.setCursor(x, 3); + gfx.print("LoRa --"); + } + x += 50; + + // BLE indicator + if (_bleActive) { + gfx.setTextColor(Theme::ACCENT, Theme::BG); + gfx.setCursor(x, 3); + gfx.print("BLE"); + x += 30; + } + + // WiFi indicator + if (_wifiActive) { + gfx.setTextColor(Theme::PRIMARY, Theme::BG); + gfx.setCursor(x, 3); + gfx.print("WiFi"); + } + + // Battery (right side) + if (_battPct >= 0) { + char buf[8]; + snprintf(buf, sizeof(buf), "%d%%", _battPct); + uint32_t col = Theme::PRIMARY; + if (_battPct <= 15) col = Theme::ERROR_CLR; + else if (_battPct <= 30) col = Theme::WARNING_CLR; + gfx.setTextColor(col, Theme::BG); + gfx.setCursor(Theme::SCREEN_W - 60, 3); + gfx.print(buf); + } + + // Uptime (far right) — minutes only to avoid per-second redraws + unsigned long mins = millis() / 60000; + char timeBuf[16]; + if (mins >= 60) { + snprintf(timeBuf, sizeof(timeBuf), "%luh%lum", mins / 60, mins % 60); + } else { + snprintf(timeBuf, sizeof(timeBuf), "%lum", mins); + } + gfx.setTextColor(Theme::SECONDARY, Theme::BG); + gfx.setCursor(Theme::SCREEN_W - 36, 3); + gfx.print(timeBuf); + + // Bottom border line + gfx.drawFastHLine(0, Theme::STATUS_BAR_H - 1, Theme::SCREEN_W, Theme::BORDER); +} + +void StatusBar::flashAnnounce() { + _announceFlashEnd = millis() + 1000; + markDirty(); +} + +void StatusBar::showToast(const char* msg, uint32_t durationMs) { + _toastMsg = msg; + _toastEnd = millis() + durationMs; + markDirty(); +} diff --git a/src/ui/StatusBar.h b/src/ui/StatusBar.h new file mode 100644 index 0000000..7a62172 --- /dev/null +++ b/src/ui/StatusBar.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +class LGFX_TDeck; + +class StatusBar { +public: + void setGfx(LGFX_TDeck* gfx) { _gfx = gfx; } + void draw(LGFX_TDeck& gfx); + void update(); + + void setLoRaOnline(bool online) { _loraOnline = online; markDirty(); } + void setBLEActive(bool active) { _bleActive = active; markDirty(); } + void setWiFiActive(bool active) { _wifiActive = active; markDirty(); } + void setBatteryPercent(int pct) { if (_battPct != pct) { _battPct = pct; markDirty(); } } + void setTransportMode(const char* mode) { _transportMode = mode; markDirty(); } + void flashAnnounce(); + void showToast(const char* msg, uint32_t durationMs = 1500); + + bool isDirty() const { return _dirty; } + void markDirty() { _dirty = true; } + void clearDirty() { _dirty = false; } + + friend class UIManager; + +private: + LGFX_TDeck* _gfx = nullptr; + bool _dirty = true; + + bool _loraOnline = false; + bool _bleActive = false; + bool _wifiActive = false; + int _battPct = -1; + std::string _transportMode; + unsigned long _announceFlashEnd = 0; + + // Toast notification + std::string _toastMsg; + unsigned long _toastEnd = 0; +}; diff --git a/src/ui/TabBar.cpp b/src/ui/TabBar.cpp new file mode 100644 index 0000000..b8c7da6 --- /dev/null +++ b/src/ui/TabBar.cpp @@ -0,0 +1,64 @@ +#include "TabBar.h" +#include "Theme.h" +#include "hal/Display.h" +#include +#include + +constexpr const char* TabBar::TAB_NAMES[5]; + +void TabBar::draw(LGFX_TDeck& gfx) { + int y = Theme::SCREEN_H - Theme::TAB_BAR_H; + + // Clear tab bar area + gfx.fillRect(0, y, Theme::SCREEN_W, Theme::TAB_BAR_H, Theme::BG); + + // Top border + gfx.drawFastHLine(0, y, Theme::SCREEN_W, Theme::BORDER); + + gfx.setTextSize(1); + + for (int i = 0; i < 5; i++) { + int tx = i * Theme::TAB_W; + + // Active tab indicator + if (i == _activeTab) { + gfx.drawFastHLine(tx + 2, y, Theme::TAB_W - 4, Theme::ACCENT); + } + + // Tab label + uint32_t col = (i == _activeTab) ? Theme::TAB_ACTIVE : Theme::TAB_INACTIVE; + gfx.setTextColor(col, Theme::BG); + + char label[24]; + if (_unread[i] > 0) { + snprintf(label, sizeof(label), "%s(%d)", TAB_NAMES[i], _unread[i]); + } else { + snprintf(label, sizeof(label), "%s", TAB_NAMES[i]); + } + + // Center text in tab + int textW = strlen(label) * 6; // 6px per char at textSize 1 + int textX = tx + (Theme::TAB_W - textW) / 2; + gfx.setCursor(textX, y + 4); + gfx.print(label); + } +} + +void TabBar::setActiveTab(int tab) { + if (tab >= 0 && tab < 5) { + _activeTab = tab; + markDirty(); + } +} + +void TabBar::cycleTab(int direction) { + _activeTab = (_activeTab + direction + 5) % 5; + markDirty(); +} + +void TabBar::setUnreadCount(int tab, int count) { + if (tab >= 0 && tab < 5) { + _unread[tab] = count; + markDirty(); + } +} diff --git a/src/ui/TabBar.h b/src/ui/TabBar.h new file mode 100644 index 0000000..79210c1 --- /dev/null +++ b/src/ui/TabBar.h @@ -0,0 +1,36 @@ +#pragma once + +class LGFX_TDeck; + +class TabBar { +public: + enum Tab { TAB_HOME = 0, TAB_MSGS, TAB_NODES, TAB_MAP, TAB_SETUP }; + + void setGfx(LGFX_TDeck* gfx) { _gfx = gfx; } + void draw(LGFX_TDeck& gfx); + + void setActiveTab(int tab); + int getActiveTab() const { return _activeTab; } + void cycleTab(int direction); + + void setUnreadCount(int tab, int count); + + using TabCallback = void(*)(int tab); + void setTabCallback(TabCallback cb) { _tabCb = cb; } + + bool isDirty() const { return _dirty; } + void markDirty() { _dirty = true; } + void clearDirty() { _dirty = false; } + + friend class UIManager; + +private: + LGFX_TDeck* _gfx = nullptr; + bool _dirty = true; + + int _activeTab = TAB_HOME; + int _unread[5] = {}; + TabCallback _tabCb = nullptr; + + static constexpr const char* TAB_NAMES[5] = {"Home", "Msgs", "Nodes", "Map", "Setup"}; +}; diff --git a/src/ui/Theme.cpp b/src/ui/Theme.cpp new file mode 100644 index 0000000..e58a507 --- /dev/null +++ b/src/ui/Theme.cpp @@ -0,0 +1,2 @@ +#include "Theme.h" +// Theme is now compile-time constants only — no runtime init needed. diff --git a/src/ui/Theme.h b/src/ui/Theme.h new file mode 100644 index 0000000..6f79838 --- /dev/null +++ b/src/ui/Theme.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +// ============================================================================= +// Ratdeck — Cyberpunk Theme Constants (LovyanGFX direct drawing) +// ============================================================================= + +namespace Theme { + +// --- Colors (RGB888 for LovyanGFX) --- +constexpr uint32_t BG = 0x000000; // Pure black +constexpr uint32_t PRIMARY = 0x00FF41; // Matrix green +constexpr uint32_t SECONDARY = 0x00CC33; // Dimmed green +constexpr uint32_t ACCENT = 0x00FFFF; // Cyan +constexpr uint32_t MUTED = 0x336633; // Dark green +constexpr uint32_t ERROR_CLR = 0xFF3333; // Red +constexpr uint32_t WARNING_CLR = 0xFFFF00; // Yellow +constexpr uint32_t BORDER = 0x004400; // Subtle green +constexpr uint32_t SELECTION_BG = 0x003300; // Highlight +constexpr uint32_t MSG_OUT_BG = 0x002200; // Outgoing bubble +constexpr uint32_t MSG_IN_BG = 0x1A1A2E; // Incoming bubble +constexpr uint32_t TAB_ACTIVE = 0x00FF41; +constexpr uint32_t TAB_INACTIVE = 0x336633; +constexpr uint32_t BADGE_BG = 0xFF3333; + +// --- Layout Metrics --- +constexpr int SCREEN_W = 320; +constexpr int SCREEN_H = 240; +constexpr int STATUS_BAR_H = 14; +constexpr int TAB_BAR_H = 14; +constexpr int CONTENT_Y = STATUS_BAR_H; +constexpr int CONTENT_H = SCREEN_H - STATUS_BAR_H - TAB_BAR_H; +constexpr int CONTENT_W = SCREEN_W; + +// --- Tab Bar --- +constexpr int TAB_COUNT = 5; +constexpr int TAB_W = SCREEN_W / TAB_COUNT; + +} // namespace Theme diff --git a/src/ui/UIManager.cpp b/src/ui/UIManager.cpp new file mode 100644 index 0000000..fe275a4 --- /dev/null +++ b/src/ui/UIManager.cpp @@ -0,0 +1,103 @@ +#include "UIManager.h" +#include "Theme.h" +#include "hal/Display.h" + +void UIManager::begin(LGFX_TDeck* gfx) { + _gfx = gfx; + _statusBar.setGfx(gfx); + _tabBar.setGfx(gfx); +} + +void UIManager::setScreen(Screen* screen) { + if (_currentScreen) { + _currentScreen->onExit(); + } + + _currentScreen = screen; + + if (_currentScreen) { + _currentScreen->onEnter(); + _currentScreen->markDirty(); + } + + forceRedraw(); +} + +void UIManager::setOverlay(Screen* overlay) { + _overlay = overlay; + if (_overlay) { + _overlay->markDirty(); + } + forceRedraw(); +} + +void UIManager::setBootMode(bool boot) { + _bootMode = boot; + forceRedraw(); +} + +void UIManager::update() { + _statusBar.update(); + if (_currentScreen) _currentScreen->update(); +} + +void UIManager::render() { + if (!_gfx) return; + + bool needStatusRedraw = _statusBar.isDirty(); + bool needTabRedraw = _tabBar.isDirty(); + bool needContentRedraw = (_currentScreen && _currentScreen->isDirty()); + bool needOverlayRedraw = (_overlay && _overlay->isDirty()); + + if (!needStatusRedraw && !needTabRedraw && !needContentRedraw && !needOverlayRedraw) + return; + + if (!_bootMode && needStatusRedraw) { + _statusBar.draw(*_gfx); + _statusBar.clearDirty(); + } + + if (needContentRedraw) { + if (_bootMode) { + // In boot mode, content area is full screen + _gfx->setClipRect(0, 0, Theme::SCREEN_W, Theme::SCREEN_H); + } else { + _gfx->setClipRect(0, Theme::CONTENT_Y, Theme::CONTENT_W, Theme::CONTENT_H); + } + _gfx->fillRect(0, _bootMode ? 0 : Theme::CONTENT_Y, + Theme::CONTENT_W, _bootMode ? Theme::SCREEN_H : Theme::CONTENT_H, + Theme::BG); + _currentScreen->draw(*_gfx); + _currentScreen->clearDirty(); + _gfx->clearClipRect(); + } + + if (needOverlayRedraw && _overlay) { + _gfx->setClipRect(0, Theme::CONTENT_Y, Theme::CONTENT_W, Theme::CONTENT_H); + _overlay->draw(*_gfx); + _overlay->clearDirty(); + _gfx->clearClipRect(); + } + + if (!_bootMode && needTabRedraw) { + _tabBar.draw(*_gfx); + _tabBar.clearDirty(); + } +} + +void UIManager::forceRedraw() { + _statusBar.markDirty(); + _tabBar.markDirty(); + if (_currentScreen) _currentScreen->markDirty(); + if (_overlay) _overlay->markDirty(); +} + +bool UIManager::handleKey(const KeyEvent& event) { + if (_overlay) { + return _overlay->handleKey(event); + } + if (_currentScreen) { + return _currentScreen->handleKey(event); + } + return false; +} diff --git a/src/ui/UIManager.h b/src/ui/UIManager.h new file mode 100644 index 0000000..57fc469 --- /dev/null +++ b/src/ui/UIManager.h @@ -0,0 +1,65 @@ +#pragma once + +#include "StatusBar.h" +#include "TabBar.h" +#include "hal/Keyboard.h" + +class LGFX_TDeck; + +class Screen { +public: + virtual ~Screen() = default; + virtual void onEnter() {} + virtual void onExit() {} + virtual void update() {} + virtual bool handleKey(const KeyEvent& event) { return false; } + virtual const char* title() const = 0; + virtual void draw(LGFX_TDeck& gfx) = 0; + + bool isDirty() const { return _dirty; } + void markDirty() { _dirty = true; } + void clearDirty() { _dirty = false; } + +protected: + bool _dirty = true; +}; + +class UIManager { +public: + void begin(LGFX_TDeck* gfx); + + // Screen management + void setScreen(Screen* screen); + Screen* getScreen() { return _currentScreen; } + + // Component access + StatusBar& statusBar() { return _statusBar; } + TabBar& tabBar() { return _tabBar; } + + // Update data (called periodically) + void update(); + + // Render if dirty (called from loop) + void render(); + + // Force full redraw + void forceRedraw(); + + // Handle key event — routes to current screen + bool handleKey(const KeyEvent& event); + + // Boot mode — hides status bar and tab bar + void setBootMode(bool boot); + bool isBootMode() const { return _bootMode; } + + // Overlay support + void setOverlay(Screen* overlay); + +private: + LGFX_TDeck* _gfx = nullptr; + StatusBar _statusBar; + TabBar _tabBar; + Screen* _currentScreen = nullptr; + Screen* _overlay = nullptr; + bool _bootMode = false; +}; diff --git a/src/ui/screens/BootScreen.cpp b/src/ui/screens/BootScreen.cpp new file mode 100644 index 0000000..4f9206d --- /dev/null +++ b/src/ui/screens/BootScreen.cpp @@ -0,0 +1,55 @@ +#include "BootScreen.h" +#include "ui/Theme.h" +#include "config/Config.h" +#include "hal/Display.h" + +void BootScreen::draw(LGFX_TDeck& gfx) { + int cx = Theme::SCREEN_W / 2; + + // Title + gfx.setTextSize(2); + gfx.setTextColor(Theme::PRIMARY, Theme::BG); + const char* title = "RATDECK"; + int tw = strlen(title) * 12; // 12px per char at size 2 + gfx.setCursor(cx - tw / 2, 70); + gfx.print(title); + + // Version + gfx.setTextSize(1); + gfx.setTextColor(Theme::SECONDARY, Theme::BG); + char ver[32]; + snprintf(ver, sizeof(ver), "v%s", RATDECK_VERSION_STRING); + int vw = strlen(ver) * 6; + gfx.setCursor(cx - vw / 2, 95); + gfx.print(ver); + + // Progress bar background + int barX = cx - 100; + int barY = 120; + int barW = 200; + int barH = 10; + gfx.fillRect(barX, barY, barW, barH, Theme::BORDER); + + // Progress bar fill + int fillW = (int)(barW * _progress); + if (fillW > 0) { + gfx.fillRect(barX, barY, fillW, barH, Theme::PRIMARY); + } + + // Status text + gfx.setTextSize(1); + gfx.setTextColor(Theme::SECONDARY, Theme::BG); + int sw = strlen(_status) * 6; + gfx.setCursor(cx - sw / 2, 145); + gfx.print(_status); +} + +void BootScreen::setProgress(float progress, const char* status) { + _progress = progress; + strncpy(_status, status, sizeof(_status) - 1); + _status[sizeof(_status) - 1] = '\0'; + markDirty(); + + // Force immediate render during boot (no main loop running yet) + // Caller (main.cpp) must call ui.render() after this +} diff --git a/src/ui/screens/BootScreen.h b/src/ui/screens/BootScreen.h new file mode 100644 index 0000000..21d9ef6 --- /dev/null +++ b/src/ui/screens/BootScreen.h @@ -0,0 +1,15 @@ +#pragma once + +#include "ui/UIManager.h" + +class BootScreen : public Screen { +public: + void setProgress(float progress, const char* status); + + const char* title() const override { return "Boot"; } + void draw(LGFX_TDeck& gfx) override; + +private: + float _progress = 0; + char _status[64] = "Starting..."; +}; diff --git a/src/ui/screens/HelpOverlay.cpp b/src/ui/screens/HelpOverlay.cpp new file mode 100644 index 0000000..31af1bb --- /dev/null +++ b/src/ui/screens/HelpOverlay.cpp @@ -0,0 +1,52 @@ +#include "HelpOverlay.h" +#include "ui/Theme.h" +#include "hal/Display.h" + +void HelpOverlay::draw(LGFX_TDeck& gfx) { + if (!_visible) return; + + // Semi-transparent overlay box + int bx = 20, by = Theme::CONTENT_Y + 10; + int bw = Theme::CONTENT_W - 40; + int bh = Theme::CONTENT_H - 20; + + gfx.fillRect(bx, by, bw, bh, Theme::BG); + gfx.drawRect(bx, by, bw, bh, Theme::ACCENT); + + gfx.setTextSize(1); + int x = bx + 8; + int y = by + 8; + int lineH = 12; + + // Title + gfx.setTextColor(Theme::ACCENT, Theme::BG); + gfx.setCursor(x, y); + gfx.print("HOTKEYS"); + y += lineH + 4; + + const char* lines[] = { + "Ctrl+H This help", + "Ctrl+M Messages", + "Ctrl+N New message", + "Ctrl+S Settings", + "Ctrl+A Force announce", + "Ctrl+D Diagnostics (serial)", + "Ctrl+T Radio test TX", + "Ctrl+R RSSI monitor", + ", / Cycle tabs", + "; . Scroll up/down", + "Esc Back", + }; + + gfx.setTextColor(Theme::PRIMARY, Theme::BG); + for (const char* line : lines) { + gfx.setCursor(x, y); + gfx.print(line); + y += lineH; + } +} + +bool HelpOverlay::handleKey(const KeyEvent& event) { + _visible = false; + return true; +} diff --git a/src/ui/screens/HelpOverlay.h b/src/ui/screens/HelpOverlay.h new file mode 100644 index 0000000..a3c23da --- /dev/null +++ b/src/ui/screens/HelpOverlay.h @@ -0,0 +1,17 @@ +#pragma once + +#include "ui/UIManager.h" + +class HelpOverlay : public Screen { +public: + bool handleKey(const KeyEvent& event) override; + + void toggle() { _visible = !_visible; } + bool isVisible() const { return _visible; } + + const char* title() const override { return "Help"; } + void draw(LGFX_TDeck& gfx) override; + +private: + bool _visible = false; +}; diff --git a/src/ui/screens/HomeScreen.cpp b/src/ui/screens/HomeScreen.cpp new file mode 100644 index 0000000..708d798 --- /dev/null +++ b/src/ui/screens/HomeScreen.cpp @@ -0,0 +1,73 @@ +#include "HomeScreen.h" +#include "ui/Theme.h" +#include "hal/Display.h" +#include "reticulum/ReticulumManager.h" +#include "radio/SX1262.h" +#include "config/UserConfig.h" +#include +#include + +void HomeScreen::update() { + // Only redraw when minute changes or heap changes significantly + unsigned long upMins = millis() / 60000; + uint32_t heap = ESP.getFreeHeap() / 1024; + if (upMins != _lastUptime || heap != _lastHeap) { + _lastUptime = upMins; + _lastHeap = heap; + markDirty(); + } +} + +void HomeScreen::draw(LGFX_TDeck& gfx) { + int x = 4; + int y = Theme::CONTENT_Y + 4; + int lineH = 12; + + gfx.setTextSize(1); + + auto drawLine = [&](uint32_t col, const char* fmt, ...) { + char buf[80]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + gfx.setTextColor(col, Theme::BG); + gfx.setCursor(x, y); + gfx.print(buf); + y += lineH; + }; + + if (_rns) { + drawLine(Theme::PRIMARY, "ID: %s", _rns->identityHash().c_str()); + drawLine(Theme::PRIMARY, "Transport: %s", + _rns->isTransportActive() ? "ACTIVE" : "OFFLINE"); + drawLine(Theme::PRIMARY, "Paths: %d Links: %d", + (int)_rns->pathCount(), (int)_rns->linkCount()); + } else { + drawLine(Theme::MUTED, "Identity: ---"); + drawLine(Theme::MUTED, "Transport: OFFLINE"); + drawLine(Theme::MUTED, "Paths: 0 Links: 0"); + } + + if (_radio && _radio->isRadioOnline()) { + drawLine(Theme::PRIMARY, "LoRa: SF%d BW%luk %ddBm", + _radio->getSpreadingFactor(), + (unsigned long)(_radio->getSignalBandwidth() / 1000), + _radio->getTxPower()); + } else { + drawLine(Theme::ERROR_CLR, "Radio: OFFLINE"); + } + + drawLine(Theme::PRIMARY, "Heap: %lukB free", + (unsigned long)(ESP.getFreeHeap() / 1024)); + + drawLine(Theme::PRIMARY, "PSRAM: %lukB free", + (unsigned long)(ESP.getFreePsram() / 1024)); + + unsigned long mins = millis() / 60000; + if (mins >= 60) { + drawLine(Theme::PRIMARY, "Uptime: %luh %lum", mins / 60, mins % 60); + } else { + drawLine(Theme::PRIMARY, "Uptime: %lum", mins); + } +} diff --git a/src/ui/screens/HomeScreen.h b/src/ui/screens/HomeScreen.h new file mode 100644 index 0000000..fe3b8d6 --- /dev/null +++ b/src/ui/screens/HomeScreen.h @@ -0,0 +1,26 @@ +#pragma once + +#include "ui/UIManager.h" + +class ReticulumManager; +class SX1262; +class UserConfig; + +class HomeScreen : public Screen { +public: + void update() override; + + void setReticulumManager(ReticulumManager* rns) { _rns = rns; } + void setRadio(SX1262* radio) { _radio = radio; } + void setUserConfig(UserConfig* cfg) { _cfg = cfg; } + + const char* title() const override { return "Home"; } + void draw(LGFX_TDeck& gfx) override; + +private: + ReticulumManager* _rns = nullptr; + SX1262* _radio = nullptr; + UserConfig* _cfg = nullptr; + unsigned long _lastUptime = 0; + uint32_t _lastHeap = 0; +}; diff --git a/src/ui/screens/MapScreen.cpp b/src/ui/screens/MapScreen.cpp new file mode 100644 index 0000000..5fca616 --- /dev/null +++ b/src/ui/screens/MapScreen.cpp @@ -0,0 +1,25 @@ +#include "MapScreen.h" +#include "ui/Theme.h" +#include "hal/Display.h" + +void MapScreen::draw(LGFX_TDeck& gfx) { + gfx.setTextSize(1); + gfx.setTextColor(Theme::MUTED, Theme::BG); + + const char* lines[] = { + "Map", + "", + "Coming soon", + "", + "Node topology view", + "will appear here" + }; + + int y = Theme::CONTENT_Y + Theme::CONTENT_H / 2 - 36; + for (const char* line : lines) { + int tw = strlen(line) * 6; + gfx.setCursor(Theme::SCREEN_W / 2 - tw / 2, y); + gfx.print(line); + y += 12; + } +} diff --git a/src/ui/screens/MapScreen.h b/src/ui/screens/MapScreen.h new file mode 100644 index 0000000..cdf5047 --- /dev/null +++ b/src/ui/screens/MapScreen.h @@ -0,0 +1,9 @@ +#pragma once + +#include "ui/UIManager.h" + +class MapScreen : public Screen { +public: + const char* title() const override { return "Map"; } + void draw(LGFX_TDeck& gfx) override; +}; diff --git a/src/ui/screens/MessageView.cpp b/src/ui/screens/MessageView.cpp new file mode 100644 index 0000000..ae8664e --- /dev/null +++ b/src/ui/screens/MessageView.cpp @@ -0,0 +1,170 @@ +#include "MessageView.h" +#include "ui/Theme.h" +#include "hal/Display.h" +#include "reticulum/LXMFManager.h" +#include + +void MessageView::onEnter() { + if (_lxmf) _lxmf->markRead(_peerHex); + _lastMsgCount = -1; + _scrollOffset = 0; + markDirty(); +} + +void MessageView::onExit() { + _inputText.clear(); +} + +void MessageView::update() { + if (!_lxmf) return; + auto msgs = _lxmf->getMessages(_peerHex); + if ((int)msgs.size() != _lastMsgCount) { + _lastMsgCount = (int)msgs.size(); + markDirty(); + } +} + +void MessageView::draw(LGFX_TDeck& gfx) { + gfx.setTextSize(1); + + // Header + gfx.setTextColor(Theme::ACCENT, Theme::BG); + gfx.setCursor(4, Theme::CONTENT_Y + 2); + char header[32]; + snprintf(header, sizeof(header), "< %s", _peerHex.substr(0, 12).c_str()); + gfx.print(header); + + // Divider under header + int headerBottom = Theme::CONTENT_Y + 12; + gfx.drawFastHLine(0, headerBottom, Theme::SCREEN_W, Theme::BORDER); + + // Input area (bottom of content) + int inputY = Theme::SCREEN_H - Theme::TAB_BAR_H - 16; + gfx.drawFastHLine(0, inputY - 2, Theme::SCREEN_W, Theme::BORDER); + gfx.fillRect(0, inputY, Theme::SCREEN_W, 16, Theme::BG); + + // Input text + gfx.setTextColor(Theme::PRIMARY, Theme::BG); + gfx.setCursor(4, inputY + 4); + if (_inputText.empty()) { + gfx.setTextColor(Theme::MUTED, Theme::BG); + gfx.print("Type message..."); + } else { + // Show last ~48 chars if too long + if (_inputText.length() > 48) { + gfx.print(_inputText.substr(_inputText.length() - 48).c_str()); + } else { + gfx.print(_inputText.c_str()); + } + } + + // Cursor blink + int cursorX = 4 + (int)std::min(_inputText.length(), (size_t)48) * 6; + if ((millis() / 500) % 2 == 0) { + gfx.fillRect(cursorX, inputY + 2, 2, 10, Theme::ACCENT); + } + + // [Send] label + gfx.setTextColor(Theme::PRIMARY, Theme::SELECTION_BG); + gfx.fillRect(Theme::SCREEN_W - 32, inputY, 30, 14, Theme::SELECTION_BG); + gfx.setCursor(Theme::SCREEN_W - 30, inputY + 3); + gfx.print("Send"); + + // Message area + if (!_lxmf) return; + auto msgs = _lxmf->getMessages(_peerHex); + int msgAreaTop = headerBottom + 2; + int msgAreaBottom = inputY - 4; + int lineH = 12; + int maxLines = (msgAreaBottom - msgAreaTop) / lineH; + + // Draw messages from bottom up + int startIdx = (int)msgs.size() - maxLines - _scrollOffset; + if (startIdx < 0) startIdx = 0; + int endIdx = startIdx + maxLines; + if (endIdx > (int)msgs.size()) endIdx = (int)msgs.size(); + + int y = msgAreaTop; + for (int i = startIdx; i < endIdx; i++) { + const auto& msg = msgs[i]; + if (msg.incoming) { + gfx.setTextColor(Theme::ACCENT, Theme::BG); + gfx.setCursor(4, y); + } else { + gfx.setTextColor(Theme::PRIMARY, Theme::BG); + // Right-align outgoing + int tw = std::min((int)msg.content.length(), 40) * 6; + gfx.setCursor(Theme::SCREEN_W - tw - 4, y); + } + + // Truncate long messages to one line for now + if (msg.content.length() > 48) { + gfx.print(msg.content.substr(0, 45).c_str()); + gfx.print("..."); + } else { + gfx.print(msg.content.c_str()); + } + y += lineH; + } +} + +void MessageView::sendCurrentMessage() { + if (!_lxmf || _peerHex.empty() || _inputText.empty()) return; + + RNS::Bytes destHash; + destHash.assignHex(_peerHex.c_str()); + _lxmf->sendMessage(destHash, _inputText.c_str()); + + _inputText.clear(); + markDirty(); +} + +bool MessageView::handleKey(const KeyEvent& event) { + // Escape goes back + if (event.character == 0x1B) { + if (_onBack) _onBack(); + return true; + } + + // Backspace: delete text, or go back if input is empty + if (event.del || event.character == 0x08) { + if (!_inputText.empty()) { + _inputText.pop_back(); + markDirty(); + } else { + if (_onBack) _onBack(); + } + return true; + } + + // Enter sends message + if (event.enter || event.character == '\n' || event.character == '\r') { + sendCurrentMessage(); + return true; + } + + // Arrow keys scroll messages (Alt+I/M) + if (event.up) { + if (_scrollOffset < _lastMsgCount - 5) { + _scrollOffset++; + markDirty(); + } + return true; + } + if (event.down) { + if (_scrollOffset > 0) { + _scrollOffset--; + markDirty(); + } + return true; + } + + // Printable characters → text input + if (event.character >= 0x20 && event.character < 0x7F) { + _inputText += (char)event.character; + markDirty(); + return true; + } + + return false; +} diff --git a/src/ui/screens/MessageView.h b/src/ui/screens/MessageView.h new file mode 100644 index 0000000..6a4b986 --- /dev/null +++ b/src/ui/screens/MessageView.h @@ -0,0 +1,34 @@ +#pragma once + +#include "ui/UIManager.h" +#include +#include + +class LXMFManager; + +class MessageView : public Screen { +public: + using BackCallback = std::function; + + void update() override; + void onEnter() override; + void onExit() override; + bool handleKey(const KeyEvent& event) override; + + void setPeerHex(const std::string& hex) { _peerHex = hex; } + void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; } + void setBackCallback(BackCallback cb) { _onBack = cb; } + + const char* title() const override { return "Chat"; } + void draw(LGFX_TDeck& gfx) override; + +private: + void sendCurrentMessage(); + + LXMFManager* _lxmf = nullptr; + BackCallback _onBack; + std::string _peerHex; + std::string _inputText; + int _lastMsgCount = -1; + int _scrollOffset = 0; // scroll from bottom +}; diff --git a/src/ui/screens/MessagesScreen.cpp b/src/ui/screens/MessagesScreen.cpp new file mode 100644 index 0000000..577c091 --- /dev/null +++ b/src/ui/screens/MessagesScreen.cpp @@ -0,0 +1,95 @@ +#include "MessagesScreen.h" +#include "ui/Theme.h" +#include "hal/Display.h" +#include "reticulum/LXMFManager.h" +#include + +void MessagesScreen::onEnter() { + _lastConvCount = -1; + _selectedIdx = 0; + markDirty(); +} + +void MessagesScreen::update() { + if (!_lxmf) return; + int convCount = (int)_lxmf->conversations().size(); + if (convCount != _lastConvCount) { + _lastConvCount = convCount; + markDirty(); + } +} + +void MessagesScreen::draw(LGFX_TDeck& gfx) { + gfx.setTextSize(1); + + if (!_lxmf || _lxmf->conversations().empty()) { + gfx.setTextColor(Theme::MUTED, Theme::BG); + const char* msg = "No conversations"; + int tw = strlen(msg) * 6; + gfx.setCursor(Theme::SCREEN_W / 2 - tw / 2, Theme::CONTENT_Y + Theme::CONTENT_H / 2 - 4); + gfx.print(msg); + return; + } + + const auto& convs = _lxmf->conversations(); + int y = Theme::CONTENT_Y + 2; + int rowH = 20; + + for (size_t i = 0; i < convs.size(); i++) { + if (y + rowH > Theme::SCREEN_H - Theme::TAB_BAR_H) break; + + const auto& peerHex = convs[i]; + int unread = _lxmf->unreadCount(peerHex); + + // Selection highlight + if ((int)i == _selectedIdx) { + gfx.fillRect(0, y, Theme::SCREEN_W, rowH, Theme::SELECTION_BG); + } + + // Peer hash + gfx.setTextColor(Theme::PRIMARY, (int)i == _selectedIdx ? Theme::SELECTION_BG : Theme::BG); + gfx.setCursor(4, y + 6); + gfx.print(peerHex.substr(0, 16).c_str()); + + // Unread badge + if (unread > 0) { + char badge[8]; + snprintf(badge, sizeof(badge), "(%d)", unread); + gfx.setTextColor(Theme::BADGE_BG, (int)i == _selectedIdx ? Theme::SELECTION_BG : Theme::BG); + gfx.setCursor(Theme::SCREEN_W - 30, y + 6); + gfx.print(badge); + } + + // Separator + gfx.drawFastHLine(0, y + rowH - 1, Theme::SCREEN_W, Theme::BORDER); + y += rowH; + } +} + +bool MessagesScreen::handleKey(const KeyEvent& event) { + if (!_lxmf) return false; + int count = (int)_lxmf->conversations().size(); + if (count == 0) return false; + + if (event.up) { + if (_selectedIdx > 0) { + _selectedIdx--; + markDirty(); + } + return true; + } + if (event.down) { + if (_selectedIdx < count - 1) { + _selectedIdx++; + markDirty(); + } + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedIdx < count && _onOpen) { + _onOpen(_lxmf->conversations()[_selectedIdx]); + } + return true; + } + return false; +} diff --git a/src/ui/screens/MessagesScreen.h b/src/ui/screens/MessagesScreen.h new file mode 100644 index 0000000..81e5834 --- /dev/null +++ b/src/ui/screens/MessagesScreen.h @@ -0,0 +1,28 @@ +#pragma once + +#include "ui/UIManager.h" +#include +#include + +class LXMFManager; + +class MessagesScreen : public Screen { +public: + using OpenCallback = std::function; + + void update() override; + void onEnter() override; + bool handleKey(const KeyEvent& event) override; + + void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; } + void setOpenCallback(OpenCallback cb) { _onOpen = cb; } + + const char* title() const override { return "Messages"; } + void draw(LGFX_TDeck& gfx) override; + +private: + LXMFManager* _lxmf = nullptr; + OpenCallback _onOpen; + int _lastConvCount = -1; + int _selectedIdx = 0; +}; diff --git a/src/ui/screens/NodesScreen.cpp b/src/ui/screens/NodesScreen.cpp new file mode 100644 index 0000000..eae4b77 --- /dev/null +++ b/src/ui/screens/NodesScreen.cpp @@ -0,0 +1,97 @@ +#include "NodesScreen.h" +#include "ui/Theme.h" +#include "hal/Display.h" +#include "reticulum/AnnounceManager.h" +#include + +void NodesScreen::onEnter() { + _lastNodeCount = -1; + _selectedIdx = 0; + markDirty(); +} + +void NodesScreen::update() { + if (!_am) return; + if (_am->nodeCount() != _lastNodeCount) { + _lastNodeCount = _am->nodeCount(); + markDirty(); + } +} + +void NodesScreen::draw(LGFX_TDeck& gfx) { + gfx.setTextSize(1); + + if (!_am || _am->nodeCount() == 0) { + gfx.setTextColor(Theme::MUTED, Theme::BG); + const char* msg = "No nodes discovered"; + int tw = strlen(msg) * 6; + gfx.setCursor(Theme::SCREEN_W / 2 - tw / 2, Theme::CONTENT_Y + Theme::CONTENT_H / 2 - 4); + gfx.print(msg); + return; + } + + const auto& nodes = _am->nodes(); + int y = Theme::CONTENT_Y + 2; + int rowH = 18; + + for (size_t i = 0; i < nodes.size(); i++) { + if (y + rowH > Theme::SCREEN_H - Theme::TAB_BAR_H) break; + + const auto& node = nodes[i]; + + // Selection highlight + if ((int)i == _selectedIdx) { + gfx.fillRect(0, y, Theme::SCREEN_W, rowH, Theme::SELECTION_BG); + } + + uint32_t bgCol = (int)i == _selectedIdx ? Theme::SELECTION_BG : Theme::BG; + + // Name + hash + std::string hashHex = node.hash.toHex(); + char buf[64]; + snprintf(buf, sizeof(buf), "%s [%s]", node.name.c_str(), hashHex.substr(0, 8).c_str()); + gfx.setTextColor(node.saved ? Theme::ACCENT : Theme::PRIMARY, bgCol); + gfx.setCursor(4, y + 5); + gfx.print(buf); + + // Hops + age (right side) + unsigned long ageSec = (millis() - node.lastSeen) / 1000; + char infoBuf[24]; + if (ageSec < 60) snprintf(infoBuf, sizeof(infoBuf), "%dhop %lus", node.hops, ageSec); + else snprintf(infoBuf, sizeof(infoBuf), "%dhop %lum", node.hops, ageSec / 60); + int tw = strlen(infoBuf) * 6; + gfx.setTextColor(Theme::SECONDARY, bgCol); + gfx.setCursor(Theme::SCREEN_W - tw - 4, y + 5); + gfx.print(infoBuf); + + y += rowH; + } +} + +bool NodesScreen::handleKey(const KeyEvent& event) { + if (!_am) return false; + int count = _am->nodeCount(); + if (count == 0) return false; + + if (event.up) { + if (_selectedIdx > 0) { + _selectedIdx--; + markDirty(); + } + return true; + } + if (event.down) { + if (_selectedIdx < count - 1) { + _selectedIdx++; + markDirty(); + } + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_selectedIdx < count && _onSelect) { + _onSelect(_am->nodes()[_selectedIdx].hash.toHex()); + } + return true; + } + return false; +} diff --git a/src/ui/screens/NodesScreen.h b/src/ui/screens/NodesScreen.h new file mode 100644 index 0000000..c82a92e --- /dev/null +++ b/src/ui/screens/NodesScreen.h @@ -0,0 +1,28 @@ +#pragma once + +#include "ui/UIManager.h" +#include +#include + +class AnnounceManager; + +class NodesScreen : public Screen { +public: + using NodeSelectedCallback = std::function; + + void update() override; + void onEnter() override; + bool handleKey(const KeyEvent& event) override; + + void setAnnounceManager(AnnounceManager* am) { _am = am; } + void setNodeSelectedCallback(NodeSelectedCallback cb) { _onSelect = cb; } + + const char* title() const override { return "Nodes"; } + void draw(LGFX_TDeck& gfx) override; + +private: + AnnounceManager* _am = nullptr; + NodeSelectedCallback _onSelect; + int _lastNodeCount = -1; + int _selectedIdx = 0; +}; diff --git a/src/ui/screens/SettingsScreen.cpp b/src/ui/screens/SettingsScreen.cpp new file mode 100644 index 0000000..e7de1a3 --- /dev/null +++ b/src/ui/screens/SettingsScreen.cpp @@ -0,0 +1,490 @@ +#include "SettingsScreen.h" +#include "ui/Theme.h" +#include "hal/Display.h" +#include "config/Config.h" +#include "config/UserConfig.h" +#include "storage/FlashStore.h" +#include "storage/SDStore.h" +#include "radio/SX1262.h" +#include "audio/AudioNotify.h" +#include "hal/Power.h" +#include "transport/WiFiInterface.h" +#include "reticulum/ReticulumManager.h" +#include +#include + +// Radio presets matching Ratspeak/Ratputer +struct RadioPreset { + const char* name; + uint8_t sf; + uint32_t bw; + uint8_t cr; + int8_t txPower; + long preamble; +}; + +static const RadioPreset PRESETS[] = { + {"Balanced", 9, 250000, 5, 14, 18}, // Good range/speed balance (~2.4 Kbps) + {"Long Range", 12, 125000, 8, 17, 18}, // Max range, slow (~300 bps) + {"Fast", 7, 500000, 5, 10, 18}, // Max speed, short range (~21 Kbps) +}; +static constexpr int NUM_PRESETS = 3; + +bool SettingsScreen::isEditable(int idx) const { + if (idx < 0 || idx >= (int)_items.size()) return false; + auto t = _items[idx].type; + return t == SettingType::INTEGER || t == SettingType::TOGGLE + || t == SettingType::ENUM_CHOICE || t == SettingType::ACTION; +} + +void SettingsScreen::skipToNextEditable(int dir) { + int n = (int)_items.size(); + if (n == 0) return; + int start = _selectedIdx; + for (int i = 0; i < n; i++) { + _selectedIdx += dir; + if (_selectedIdx < 0) _selectedIdx = 0; + if (_selectedIdx >= n) _selectedIdx = n - 1; + if (isEditable(_selectedIdx)) return; + if (_selectedIdx == 0 && dir < 0) return; + if (_selectedIdx == n - 1 && dir > 0) return; + } + _selectedIdx = start; +} + +int SettingsScreen::detectPreset() const { + if (!_cfg) return -1; + auto& s = _cfg->settings(); + for (int i = 0; i < NUM_PRESETS; i++) { + if (s.loraSF == PRESETS[i].sf && s.loraBW == PRESETS[i].bw + && s.loraCR == PRESETS[i].cr && s.loraTxPower == PRESETS[i].txPower) { + return i; + } + } + return -1; // Custom +} + +void SettingsScreen::applyPreset(int presetIdx) { + if (!_cfg || presetIdx < 0 || presetIdx >= NUM_PRESETS) return; + auto& s = _cfg->settings(); + const auto& p = PRESETS[presetIdx]; + s.loraSF = p.sf; + s.loraBW = p.bw; + s.loraCR = p.cr; + s.loraTxPower = p.txPower; +} + +void SettingsScreen::buildItems() { + _items.clear(); + if (!_cfg) return; + auto& s = _cfg->settings(); + + // --- Device --- + _items.push_back({"-- Device --", SettingType::HEADER, nullptr, nullptr, nullptr}); + _items.push_back({"Version", SettingType::READONLY, nullptr, nullptr, + [](int) { return String(RATDECK_VERSION_STRING); }}); + _items.push_back({"Identity", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _identityHash.substring(0, 16); }}); + + // --- Display --- + _items.push_back({"-- Display --", SettingType::HEADER, nullptr, nullptr, nullptr}); + _items.push_back({"Brightness", SettingType::INTEGER, + [&s]() { return s.brightness; }, + [&s](int v) { s.brightness = v; }, + [](int v) { return String(v); }, + 16, 255, 16}); + _items.push_back({"Dim Timeout", SettingType::INTEGER, + [&s]() { return s.screenDimTimeout; }, + [&s](int v) { s.screenDimTimeout = v; }, + [](int v) { return String(v) + "s"; }, + 5, 300, 5}); + _items.push_back({"Off Timeout", SettingType::INTEGER, + [&s]() { return s.screenOffTimeout; }, + [&s](int v) { s.screenOffTimeout = v; }, + [](int v) { return String(v) + "s"; }, + 10, 600, 10}); + + // --- Radio --- + _items.push_back({"-- Radio --", SettingType::HEADER, nullptr, nullptr, nullptr}); + + // Radio Preset selector + { + SettingItem presetItem; + presetItem.label = "Preset"; + presetItem.type = SettingType::ENUM_CHOICE; + presetItem.getter = [this]() { + int p = detectPreset(); + return (p >= 0) ? p : NUM_PRESETS; // NUM_PRESETS = "Custom" + }; + presetItem.setter = [this](int v) { + if (v >= 0 && v < NUM_PRESETS) { + applyPreset(v); + // Getter lambdas capture &s by reference — they read + // live values from _cfg->settings(). No rebuild needed. + } + // "Custom" — no-op, leave individual settings as-is + }; + presetItem.formatter = nullptr; + presetItem.minVal = 0; + presetItem.maxVal = NUM_PRESETS; // 0=Balanced, 1=Long Range, 2=Fast, 3=Custom + presetItem.step = 1; + presetItem.enumLabels = {"Balanced", "Long Range", "Fast", "Custom"}; + _items.push_back(presetItem); + } + + _items.push_back({"TX Power", SettingType::INTEGER, + [&s]() { return s.loraTxPower; }, + [&s](int v) { s.loraTxPower = v; }, + [](int v) { return String(v) + " dBm"; }, + -9, 22, 1}); + _items.push_back({"Spread Factor", SettingType::INTEGER, + [&s]() { return s.loraSF; }, + [&s](int v) { s.loraSF = v; }, + [](int v) { return String("SF") + String(v); }, + 5, 12, 1}); + _items.push_back({"Bandwidth", SettingType::ENUM_CHOICE, + [&s]() { + if (s.loraBW <= 62500) return 0; + if (s.loraBW <= 125000) return 1; + if (s.loraBW <= 250000) return 2; + return 3; // 500k + }, + [&s](int v) { + static const uint32_t bws[] = {62500, 125000, 250000, 500000}; + s.loraBW = bws[constrain(v, 0, 3)]; + }, + nullptr, 0, 3, 1, {"62.5k", "125k", "250k", "500k"}}); + _items.push_back({"Coding Rate", SettingType::INTEGER, + [&s]() { return s.loraCR; }, + [&s](int v) { s.loraCR = v; }, + [](int v) { return String("4/") + String(v); }, + 5, 8, 1}); + + // --- Actions --- + _items.push_back({"-- Actions --", SettingType::HEADER, nullptr, nullptr, nullptr}); + { + SettingItem announceItem; + announceItem.label = "Send Announce"; + announceItem.type = SettingType::ACTION; + announceItem.formatter = [](int) { return String("[Press Enter]"); }; + announceItem.action = [this]() { + if (_rns) { + _rns->announce(); + if (_ui) { + _ui->statusBar().flashAnnounce(); + _ui->statusBar().showToast("Announce sent!"); + } + Serial.println("[SETTINGS] Manual announce sent"); + } else { + if (_ui) _ui->statusBar().showToast("RNS not ready"); + } + }; + _items.push_back(announceItem); + } + { + SettingItem initSD; + initSD.label = "Init SD Card"; + initSD.type = SettingType::ACTION; + initSD.formatter = [this](int) { + return (_sd && _sd->isReady()) ? String("[Press Enter]") : String("No Card"); + }; + initSD.action = [this]() { + if (!_sd || !_sd->isReady()) { + if (_ui) _ui->statusBar().showToast("No SD card!", 1200); + return; + } + if (_ui) _ui->statusBar().showToast("Initializing SD...", 2000); + bool ok = _sd->formatForRatputer(); + if (_ui) _ui->statusBar().showToast(ok ? "SD initialized!" : "SD init failed!", 1500); + Serial.printf("[SETTINGS] SD init: %s\n", ok ? "OK" : "FAILED"); + }; + _items.push_back(initSD); + } + { + SettingItem wipeSD; + wipeSD.label = "Wipe SD Data"; + wipeSD.type = SettingType::ACTION; + wipeSD.formatter = [this](int) { + return (_sd && _sd->isReady()) ? String("[Press Enter]") : String("No Card"); + }; + wipeSD.action = [this]() { + if (!_sd || !_sd->isReady()) { + if (_ui) _ui->statusBar().showToast("No SD card!", 1200); + return; + } + if (_ui) _ui->statusBar().showToast("Wiping SD data...", 2000); + bool ok = _sd->wipeRatputer(); + if (_ui) _ui->statusBar().showToast(ok ? "SD wiped & reinit!" : "Wipe failed!", 1500); + Serial.printf("[SETTINGS] SD wipe: %s\n", ok ? "OK" : "FAILED"); + }; + _items.push_back(wipeSD); + } + + // --- Audio --- + _items.push_back({"-- Audio --", SettingType::HEADER, nullptr, nullptr, nullptr}); + _items.push_back({"Audio Enable", SettingType::TOGGLE, + [&s]() { return s.audioEnabled ? 1 : 0; }, + [&s](int v) { s.audioEnabled = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + _items.push_back({"Volume", SettingType::INTEGER, + [&s]() { return s.audioVolume; }, + [&s](int v) { s.audioVolume = v; }, + [](int v) { return String(v) + "%"; }, + 0, 100, 10}); + + // --- Input --- + _items.push_back({"-- Input --", SettingType::HEADER, nullptr, nullptr, nullptr}); + _items.push_back({"Trackball Speed", SettingType::INTEGER, + [&s]() { return s.trackballSpeed; }, + [&s](int v) { s.trackballSpeed = v; }, + [](int v) { return String(v); }, + 1, 5, 1}); + + // --- Network --- + _items.push_back({"-- Network --", SettingType::HEADER, nullptr, nullptr, nullptr}); + _items.push_back({"WiFi Mode", SettingType::ENUM_CHOICE, + [&s]() { return (int)s.wifiMode; }, + [&s](int v) { s.wifiMode = (RatWiFiMode)v; }, + nullptr, 0, 2, 1, {"OFF", "AP", "STA"}}); + _items.push_back({"BLE", SettingType::TOGGLE, + [&s]() { return s.bleEnabled ? 1 : 0; }, + [&s](int v) { s.bleEnabled = (v != 0); }, + [](int v) { return v ? String("ON") : String("OFF"); }}); + + // --- System (readonly) --- + _items.push_back({"-- System --", SettingType::HEADER, nullptr, nullptr, nullptr}); + _items.push_back({"Free Heap", SettingType::READONLY, nullptr, nullptr, + [](int) { return String((unsigned long)(ESP.getFreeHeap() / 1024)) + " KB"; }}); + _items.push_back({"Free PSRAM", SettingType::READONLY, nullptr, nullptr, + [](int) { return String((unsigned long)(ESP.getFreePsram() / 1024)) + " KB"; }}); + _items.push_back({"Flash", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _flash && _flash->exists("/ratputer") ? String("Mounted") : String("Error"); }}); + _items.push_back({"SD Card", SettingType::READONLY, nullptr, nullptr, + [this](int) { return _sd && _sd->isReady() ? String("Ready") : String("Not Found"); }}); +} + +void SettingsScreen::onEnter() { + buildItems(); + _selectedIdx = 0; + _scrollOffset = 0; + _editing = false; + // Skip to first editable item + if (!isEditable(_selectedIdx)) { + skipToNextEditable(1); + } + markDirty(); +} + +void SettingsScreen::draw(LGFX_TDeck& gfx) { + gfx.setTextSize(1); + + int lineH = 14; + int visibleLines = Theme::CONTENT_H / lineH; + int valX = 160; + + // Adjust scroll so selected item is visible + if (_selectedIdx < _scrollOffset) _scrollOffset = _selectedIdx; + if (_selectedIdx >= _scrollOffset + visibleLines) _scrollOffset = _selectedIdx - visibleLines + 1; + + for (int i = _scrollOffset; i < (int)_items.size(); i++) { + int row = i - _scrollOffset; + int y = Theme::CONTENT_Y + row * lineH; + if (y + lineH > Theme::SCREEN_H - Theme::TAB_BAR_H) break; + + const auto& item = _items[i]; + bool selected = (i == _selectedIdx); + + // Selection highlight for editable items + if (selected && isEditable(i)) { + gfx.fillRect(0, y, Theme::SCREEN_W, lineH, Theme::SELECTION_BG); + } + + uint32_t bgCol = (selected && isEditable(i)) ? Theme::SELECTION_BG : Theme::BG; + + if (item.type == SettingType::HEADER) { + // Header row — accent color + gfx.setTextColor(Theme::ACCENT, Theme::BG); + gfx.setCursor(4, y + 3); + gfx.print(item.label); + } else if (item.type == SettingType::ACTION) { + // Action button + gfx.setTextColor(selected ? Theme::ACCENT : Theme::PRIMARY, bgCol); + gfx.setCursor(4, y + 3); + gfx.print(item.label); + // Show hint on right + if (item.formatter) { + String hint = item.formatter(0); + gfx.setTextColor(Theme::MUTED, bgCol); + gfx.setCursor(valX, y + 3); + gfx.print(hint.c_str()); + } + } else { + // Label + gfx.setTextColor(Theme::SECONDARY, bgCol); + gfx.setCursor(4, y + 3); + gfx.print(item.label); + + // Value + String valStr; + if (_editing && selected) { + // Show edit value + if (item.type == SettingType::ENUM_CHOICE && !item.enumLabels.empty()) { + int idx = constrain(_editValue, 0, (int)item.enumLabels.size() - 1); + valStr = item.enumLabels[idx]; + } else if (item.formatter) { + valStr = item.formatter(_editValue); + } else { + valStr = String(_editValue); + } + // Yellow value with arrows + gfx.setTextColor(Theme::WARNING_CLR, bgCol); + gfx.setCursor(valX - 12, y + 3); + gfx.print("<"); + gfx.setCursor(valX, y + 3); + gfx.print(valStr.c_str()); + int endX = valX + (int)valStr.length() * 6 + 4; + gfx.setCursor(endX, y + 3); + gfx.print(">"); + } else { + // Normal display + if (item.type == SettingType::READONLY) { + valStr = item.formatter ? item.formatter(0) : ""; + gfx.setTextColor(Theme::MUTED, bgCol); + } else if (item.type == SettingType::ENUM_CHOICE && !item.enumLabels.empty()) { + int idx = item.getter ? constrain(item.getter(), 0, (int)item.enumLabels.size() - 1) : 0; + valStr = item.enumLabels[idx]; + gfx.setTextColor(Theme::PRIMARY, bgCol); + } else { + int val = item.getter ? item.getter() : 0; + valStr = item.formatter ? item.formatter(val) : String(val); + gfx.setTextColor(Theme::PRIMARY, bgCol); + } + gfx.setCursor(valX, y + 3); + gfx.print(valStr.c_str()); + } + } + } + + // Scroll indicator + if ((int)_items.size() > visibleLines) { + int barH = Theme::CONTENT_H; + int thumbH = max(8, barH * visibleLines / (int)_items.size()); + int thumbY = Theme::CONTENT_Y + (barH - thumbH) * _scrollOffset / max(1, (int)_items.size() - visibleLines); + gfx.fillRect(Theme::SCREEN_W - 2, Theme::CONTENT_Y, 2, barH, Theme::BORDER); + gfx.fillRect(Theme::SCREEN_W - 2, thumbY, 2, thumbH, Theme::SECONDARY); + } +} + +bool SettingsScreen::handleKey(const KeyEvent& event) { + if (_items.empty()) return false; + + if (_editing) { + // Edit mode: left/right change value, enter confirms, backspace/del cancels + auto& item = _items[_selectedIdx]; + + if (event.left) { + _editValue -= item.step; + if (_editValue < item.minVal) _editValue = item.minVal; + markDirty(); + return true; + } + if (event.right) { + _editValue += item.step; + if (_editValue > item.maxVal) _editValue = item.maxVal; + markDirty(); + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + // Confirm edit + if (item.setter) item.setter(_editValue); + _editing = false; + applyAndSave(); + markDirty(); + return true; + } + if (event.del || event.character == 8) { + // Cancel edit + _editing = false; + markDirty(); + return true; + } + return true; // Consume all keys in edit mode + } + + // Browse mode + if (event.up) { + int prev = _selectedIdx; + skipToNextEditable(-1); + if (_selectedIdx != prev) markDirty(); + return true; + } + if (event.down) { + int prev = _selectedIdx; + skipToNextEditable(1); + if (_selectedIdx != prev) markDirty(); + return true; + } + if (event.enter || event.character == '\n' || event.character == '\r') { + if (!isEditable(_selectedIdx)) return true; + auto& item = _items[_selectedIdx]; + + if (item.type == SettingType::ACTION) { + // Execute action callback + if (item.action) item.action(); + markDirty(); + } else if (item.type == SettingType::TOGGLE) { + // Toggle immediately + int val = item.getter ? item.getter() : 0; + if (item.setter) item.setter(val ? 0 : 1); + applyAndSave(); + markDirty(); + } else { + // Enter edit mode + _editing = true; + _editValue = item.getter ? item.getter() : 0; + markDirty(); + } + return true; + } + return false; +} + +void SettingsScreen::applyAndSave() { + if (!_cfg) return; + auto& s = _cfg->settings(); + + // Apply to hardware immediately (regardless of save outcome) + if (_power) { + _power->setBrightness(s.brightness); + _power->setDimTimeout(s.screenDimTimeout); + _power->setOffTimeout(s.screenOffTimeout); + } + if (_radio && _radio->isRadioOnline()) { + _radio->setTxPower(s.loraTxPower); + _radio->setSpreadingFactor(s.loraSF); + _radio->setSignalBandwidth(s.loraBW); + _radio->setCodingRate4(s.loraCR); + _radio->receive(); + } + if (_audio) { + _audio->setEnabled(s.audioEnabled); + _audio->setVolume(s.audioVolume); + } + + // Save to persistent storage + bool saved = false; + if (_saveCallback) { + saved = _saveCallback(); + } else if (_sd && _flash) { + saved = _cfg->save(*_sd, *_flash); + } else if (_flash) { + saved = _cfg->save(*_flash); + } + + // Toast: differentiate between full save and apply-only + if (_ui) { + _ui->statusBar().showToast(saved ? "Settings saved" : "Applied (save failed)", 1200); + } + + Serial.printf("[SETTINGS] Applied to hardware, save=%s\n", saved ? "OK" : "FAILED"); +} diff --git a/src/ui/screens/SettingsScreen.h b/src/ui/screens/SettingsScreen.h new file mode 100644 index 0000000..243493c --- /dev/null +++ b/src/ui/screens/SettingsScreen.h @@ -0,0 +1,90 @@ +#pragma once + +#include "ui/UIManager.h" +#include +#include +#include + +class UserConfig; +class FlashStore; +class SDStore; +class SX1262; +class AudioNotify; +class Power; +class WiFiInterface; +class TCPClientInterface; +class ReticulumManager; + +enum class SettingType : uint8_t { + HEADER, + READONLY, + INTEGER, + TOGGLE, + ENUM_CHOICE, + ACTION // Button — triggers callback on Enter +}; + +struct SettingItem { + const char* label; + SettingType type; + std::function getter; + std::function setter; + std::function formatter; + int minVal = 0; + int maxVal = 1; + int step = 1; + // For ENUM_CHOICE: list of option labels + std::vector enumLabels; + // For ACTION: callback on Enter + std::function action; +}; + +class SettingsScreen : public Screen { +public: + void onEnter() override; + bool handleKey(const KeyEvent& event) override; + + void setUserConfig(UserConfig* cfg) { _cfg = cfg; } + void setFlashStore(FlashStore* fs) { _flash = fs; } + void setSDStore(SDStore* sd) { _sd = sd; } + void setRadio(SX1262* radio) { _radio = radio; } + void setAudio(AudioNotify* audio) { _audio = audio; } + void setPower(Power* power) { _power = power; } + void setWiFi(WiFiInterface* wifi) { _wifi = wifi; } + void setTCPClients(std::vector* tcp) { _tcp = tcp; } + void setRNS(ReticulumManager* rns) { _rns = rns; } + void setUIManager(UIManager* ui) { _ui = ui; } + void setIdentityHash(const String& hash) { _identityHash = hash; } + void setSaveCallback(std::function cb) { _saveCallback = cb; } + + const char* title() const override { return "Settings"; } + void draw(LGFX_TDeck& gfx) override; + +private: + void buildItems(); + void applyAndSave(); + void applyPreset(int presetIdx); + int detectPreset() const; + void skipToNextEditable(int dir); + bool isEditable(int idx) const; + + UserConfig* _cfg = nullptr; + FlashStore* _flash = nullptr; + SDStore* _sd = nullptr; + SX1262* _radio = nullptr; + AudioNotify* _audio = nullptr; + Power* _power = nullptr; + WiFiInterface* _wifi = nullptr; + std::vector* _tcp = nullptr; + ReticulumManager* _rns = nullptr; + UIManager* _ui = nullptr; + String _identityHash; + + std::function _saveCallback; + + std::vector _items; + int _selectedIdx = 0; + int _scrollOffset = 0; + bool _editing = false; + int _editValue = 0; +};