9 Commits

Author SHA1 Message Date
pelgraine
a23b65730a Merge branch 'main' of https://github.com/pelgraine/Meck 2026-02-10 20:51:11 +11:00
pelgraine
569794d2fe updated readme with new changes and quicklinks 2026-02-10 20:49:35 +11:00
pelgraine
ea1ca315b8 Update key mapping for opening text reader 2026-02-10 20:45:19 +11:00
pelgraine
83b3ea6275 increase on-device message history buffer from 20 to 300 messages 2026-02-10 20:33:50 +11:00
pelgraine
9c6d5138b0 fixed domino emoji sprite utf codepoint 2026-02-10 20:27:20 +11:00
pelgraine
15165bb429 New settings screen and key remapping for menu screens 2026-02-10 20:18:13 +11:00
pelgraine
c4b9952d95 updated contacts all view to display max contacts etc 2026-02-10 19:06:37 +11:00
pelgraine
ce37bf6b90 settings persistance backup to sd after saveprefs 2026-02-10 19:02:58 +11:00
pelgraine
8e98132506 Channel message view retains history after reboot 2026-02-10 18:56:53 +11:00
11 changed files with 2410 additions and 409 deletions

View File

@@ -3,6 +3,32 @@ This fork was created specifically to focus on enabling BLE companion firmware f
***Please note as of 1 Feb 2026, the T-Deck Pro repeater & usb firmware has not been finalised nor confirmed as functioning.*** ⭐
### Contents
- [T-Deck Pro Keyboard Controls](#t-deck-pro-keyboard-controls)
- [Navigation (Home Screen)](#navigation-home-screen)
- [Bluetooth (BLE)](#bluetooth-ble)
- [Clock & Timezone](#clock--timezone)
- [Channel Message Screen](#channel-message-screen)
- [Contacts Screen](#contacts-screen)
- [Sending a Direct Message](#sending-a-direct-message)
- [Repeater Admin Screen](#repeater-admin-screen)
- [Settings Screen](#settings-screen)
- [Compose Mode](#compose-mode)
- [Symbol Entry (Sym Key)](#symbol-entry-sym-key)
- [Emoji Picker](#emoji-picker)
- [About MeshCore](#about-meshcore)
- [What is MeshCore?](#what-is-meshcore)
- [Key Features](#key-features)
- [What Can You Use MeshCore For?](#what-can-you-use-meshcore-for)
- [How to Get Started](#how-to-get-started)
- [MeshCore Flasher](#meshcore-flasher)
- [MeshCore Clients](#meshcore-clients)
- [Hardware Compatibility](#-hardware-compatibility)
- [License](#-license)
- [Contributing](#contributing)
- [Road-Map / To-Do](#road-map--to-do)
- [Get Support](#-get-support)
## T-Deck Pro Keyboard Controls
The T-Deck Pro BLE companion firmware includes full keyboard support for standalone messaging without a phone.
@@ -12,11 +38,12 @@ The T-Deck Pro BLE companion firmware includes full keyboard support for standal
| Key | Action |
|-----|--------|
| W / A | Previous page |
| S / D | Next page |
| D | Next page |
| Enter | Select / Confirm |
| M | Open channel messages |
| N | Open contacts list |
| R | Open e-book reader |
| C | Open contacts list |
| E | Open e-book reader |
| S | Open settings |
| Q | Back to home screen |
### Bluetooth (BLE)
@@ -34,7 +61,7 @@ The T-Deck Pro does not include a dedicated RTC chip, so after each reboot the d
**Setting your timezone:**
Navigate to the **GPS** home page and press **U** to open the UTC offset editor.
The UTC offset can be set from the **Settings** screen (press **S** from the home screen), or from the **GPS** home page by pressing **U** to open the UTC offset editor.
| Key | Action |
|-----|--------|
@@ -53,24 +80,23 @@ The GPS page also shows the current time, satellite count, position, altitude, a
|-----|--------|
| W / S | Scroll messages up/down |
| A / D | Switch between channels |
| C | Compose new message |
| Enter | Compose new message |
| Q | Back to home screen |
### Contacts Screen
Press **N** from the home screen to open the contacts list. All known mesh contacts are shown sorted by most recently seen, with their type (Chat, Repeater, Room, Sensor), hop count, and time since last advert.
Press **C** from the home screen to open the contacts list. All known mesh contacts are shown sorted by most recently seen, with their type (Chat, Repeater, Room, Sensor), hop count, and time since last advert.
| Key | Action |
|-----|--------|
| W / S | Scroll up / down through contacts |
| A / D | Cycle filter: All → Chat → Repeater → Room → Sensor |
| Enter | Open DM compose (Chat contact) or repeater admin (Repeater contact) |
| C | Open DM compose to selected chat contact |
| Q | Back to home screen |
### Sending a Direct Message
Select a **Chat** contact in the contacts list and press **Enter** or **C** to start composing a direct message. The compose screen will show `DM: ContactName` in the header. Type your message and press **Enter** to send. The DM is sent encrypted directly to that contact (or flooded if no direct path is known). After sending or cancelling, you're returned to the contacts list.
Select a **Chat** contact in the contacts list and press **Enter** to start composing a direct message. The compose screen will show `DM: ContactName` in the header. Type your message and press **Enter** to send. The DM is sent encrypted directly to that contact (or flooded if no direct path is known). After sending or cancelling, you're returned to the contacts list.
### Repeater Admin Screen
@@ -91,11 +117,39 @@ After a successful login, you'll see a menu with the following remote administra
|-----|--------|
| W / S | Navigate menu items |
| Enter | Execute selected command |
| C | Enter compose mode (send raw CLI command) |
| Q | Back to contacts (from menu) or cancel login |
Command responses are displayed in a scrollable view. Use **W / S** to scroll long responses and **Q** to return to the menu.
### Settings Screen
Press **S** from the home screen to open settings. On first boot (when the device name is still the default hex ID), the settings screen launches automatically as an onboarding wizard to set your device name and radio preset.
| Key | Action |
|-----|--------|
| W / S | Navigate up / down through settings |
| Enter | Edit selected setting |
| Q | Back to home screen |
**Available settings:**
| Setting | Edit Method |
|---------|-------------|
| Device Name | Text entry — type a name, Enter to confirm |
| Radio Preset | A / D to cycle presets (MeshCore Default, Long Range, Fast/Short, EU Default), Enter to apply |
| Frequency | W / S to adjust, Enter to confirm |
| Bandwidth | W / S to cycle standard values (31.25 / 62.5 / 125 / 250 / 500 kHz), Enter to confirm |
| Spreading Factor | W / S to adjust (512), Enter to confirm |
| Coding Rate | W / S to adjust (58), Enter to confirm |
| TX Power | W / S to adjust (120 dBm), Enter to confirm |
| UTC Offset | W / S to adjust (-12 to +14), Enter to confirm |
| Channels | View existing channels, add hashtag channels, or delete non-primary channels (X) |
| Device Info | Public key and firmware version (read-only) |
When adding a hashtag channel, type the channel name and press Enter. The channel secret is automatically derived from the name via SHA-256, matching the standard MeshCore hashtag convention.
If you've changed radio parameters, pressing Q will prompt you to apply changes before exiting.
### Compose Mode
| Key | Action |
@@ -235,6 +289,7 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Contacts list with filtering for Companion BLE firmware
- [X] Standalone repeater admin access for Companion BLE firmware
- [X] GPS time sync with on-device timezone setting
- [X] Settings screen with radio presets, channel management, and first-boot onboarding
- [ ] Companion radio: USB
- [ ] Simple Repeater firmware for the T-Deck Pro
- [ ] Get pin 45 with the screen backlight functioning for the T-Deck Pro v1.1

View File

@@ -12,12 +12,11 @@ This adds a text reader accessible via the **R** key from the home screen.
- Automatic reading position resume (persisted to SD card)
- Index files cached to SD for instant re-opens
- Bookmark indicator (`*`) on files with saved positions
- Compose mode (`C`) still accessible from within reader
**Key Mapping:**
| Context | Key | Action |
|---------|-----|--------|
| Home screen | R | Open text reader |
| Home screen | E | Open text reader |
| File list | W/S | Navigate up/down |
| File list | Enter | Open selected file |
| File list | Q | Back to home screen |
@@ -114,4 +113,4 @@ The conversion is handled by three components:
- Page content is pre-read from SD into a memory buffer during `handleInput()`, then rendered from buffer during `render()` — this avoids SPI bus conflicts during display refresh
- Layout metrics (chars per line, lines per page) are calculated dynamically from the display driver's font metrics on first entry
- EPUB conversion runs synchronously in `openBook()` — the e-ink splash screen keeps the user informed while the ESP32 processes the archive
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause
- ZIP extraction uses the ESP32-S3's hardware-optimised ROM `tinfl` inflate, avoiding external compression library dependencies and the linker conflicts they cause

View File

@@ -12,7 +12,7 @@
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.8.2"
#define FIRMWARE_VERSION "Meck v0.8.3"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -72,6 +72,11 @@
/* -------------------------------------------------------------------------------------- */
// SD-backed settings persistence (defined in main.cpp for T-Deck Pro)
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
extern void backupSettingsToSD();
#endif
#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS
#define REQ_TYPE_KEEP_ALIVE 0x02
#define REQ_TYPE_GET_TELEMETRY_DATA 0x03
@@ -169,7 +174,24 @@ protected:
}
public:
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
void savePrefs() {
_store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon);
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
backupSettingsToSD();
#endif
}
void saveChannels() {
_store->saveChannels(this);
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
backupSettingsToSD();
#endif
}
void saveContacts() {
_store->saveContacts(this);
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
backupSettingsToSD();
#endif
}
private:
void writeOKFrame();
@@ -189,10 +211,6 @@ private:
void checkCLIRescueCmd();
void checkSerialInterface();
// helpers, short-cuts
void saveChannels() { _store->saveChannels(this); }
void saveContacts() { _store->saveContacts(this); }
DataStore* _store;
NodePrefs _prefs;
uint32_t pending_login;

View File

@@ -10,6 +10,8 @@
#include <SD.h>
#include "TextReaderScreen.h"
#include "ContactsScreen.h"
#include "ChannelScreen.h"
#include "SettingsScreen.h"
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
@@ -44,6 +46,105 @@
void drawComposeScreen();
void drawEmojiPicker();
void sendComposedMessage();
// SD-backed persistence state
static bool sdCardReady = false;
// ---------------------------------------------------------------------------
// SD Settings Backup / Restore
// ---------------------------------------------------------------------------
// Copies a file byte-for-byte between two filesystem objects.
// Works across SPIFFS <-> SD because both use the Arduino File API.
static bool copyFile(fs::FS& srcFS, const char* srcPath,
fs::FS& dstFS, const char* dstPath) {
File src = srcFS.open(srcPath, "r");
if (!src) return false;
File dst = dstFS.open(dstPath, "w", true);
if (!dst) { src.close(); return false; }
uint8_t buf[128];
while (src.available()) {
int n = src.read(buf, sizeof(buf));
if (n > 0) dst.write(buf, n);
}
src.close();
dst.close();
return true;
}
// Backup prefs, channels, and identity from SPIFFS to SD card.
// Called after any savePrefs() to keep the SD mirror current.
void backupSettingsToSD() {
if (!sdCardReady) return;
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
if (SPIFFS.exists("/new_prefs")) {
copyFile(SPIFFS, "/new_prefs", SD, "/meshcore/prefs.bin");
}
// Channels may live on SPIFFS or ExtraFS - on ESP32 they are on SPIFFS
if (SPIFFS.exists("/channels2")) {
copyFile(SPIFFS, "/channels2", SD, "/meshcore/channels.bin");
}
// Identity
if (SPIFFS.exists("/identity/_main.id")) {
if (!SD.exists("/meshcore/identity")) SD.mkdir("/meshcore/identity");
copyFile(SPIFFS, "/identity/_main.id", SD, "/meshcore/identity/_main.id");
}
// Contacts
if (SPIFFS.exists("/contacts3")) {
copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin");
}
digitalWrite(SDCARD_CS, HIGH); // Release SD CS
Serial.println("Settings backed up to SD");
}
// Restore prefs, channels, and identity from SD card to SPIFFS.
// Called at boot if SPIFFS prefs file is missing (e.g. after a fresh flash).
// Returns true if anything was restored.
bool restoreSettingsFromSD() {
if (!sdCardReady) return false;
bool restored = false;
// Only restore if SPIFFS is missing the prefs file (fresh flash)
if (!SPIFFS.exists("/new_prefs") && SD.exists("/meshcore/prefs.bin")) {
if (copyFile(SD, "/meshcore/prefs.bin", SPIFFS, "/new_prefs")) {
Serial.println("Restored prefs from SD");
restored = true;
}
}
if (!SPIFFS.exists("/channels2") && SD.exists("/meshcore/channels.bin")) {
if (copyFile(SD, "/meshcore/channels.bin", SPIFFS, "/channels2")) {
Serial.println("Restored channels from SD");
restored = true;
}
}
// Identity - most critical; keeps the same device pub key across reflashes
if (!SPIFFS.exists("/identity/_main.id") && SD.exists("/meshcore/identity/_main.id")) {
SPIFFS.mkdir("/identity");
if (copyFile(SD, "/meshcore/identity/_main.id", SPIFFS, "/identity/_main.id")) {
Serial.println("Restored identity from SD");
restored = true;
}
}
if (!SPIFFS.exists("/contacts3") && SD.exists("/meshcore/contacts.bin")) {
if (copyFile(SD, "/meshcore/contacts.bin", SPIFFS, "/contacts3")) {
Serial.println("Restored contacts from SD");
restored = true;
}
}
if (restored) {
Serial.println("=== Settings restored from SD card backup ===");
}
digitalWrite(SDCARD_CS, HIGH);
return restored;
}
#endif
// Believe it or not, this std C function is busted on some platforms!
@@ -289,7 +390,31 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - ESP32 filesystem init - calling SPIFFS.begin()");
SPIFFS.begin(true);
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
// ---------------------------------------------------------------------------
// Early SD card init — needed BEFORE the_mesh.begin() so we can restore
// settings from a previous firmware flash. The display SPI bus is already
// up (display.begin() ran earlier), so SD can share it now.
// ---------------------------------------------------------------------------
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
{
pinMode(SDCARD_CS, OUTPUT);
digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially
if (SD.begin(SDCARD_CS, displaySpi, 4000000)) {
sdCardReady = true;
MESH_DEBUG_PRINTLN("setup() - SD card initialized (early)");
// If SPIFFS was wiped (fresh flash), restore settings from SD backup
if (restoreSettingsFromSD()) {
MESH_DEBUG_PRINTLN("setup() - Settings restored from SD backup");
}
} else {
MESH_DEBUG_PRINTLN("setup() - SD card not available");
}
}
#endif
MESH_DEBUG_PRINTLN("setup() - about to call store.begin()");
store.begin();
MESH_DEBUG_PRINTLN("setup() - store.begin() done");
@@ -323,6 +448,12 @@ void setup() {
the_mesh.startInterface(serial_interface);
MESH_DEBUG_PRINTLN("setup() - the_mesh.startInterface() done");
// T-Deck Pro: default BLE to OFF on boot (user can toggle with Bluetooth page)
#if defined(LilyGo_TDeck_Pro)
serial_interface.disable();
MESH_DEBUG_PRINTLN("setup() - BLE disabled by default (toggle via home screen)");
#endif
#else
#error "need to define filesystem"
#endif
@@ -350,23 +481,49 @@ void setup() {
initKeyboard();
#endif
// Initialize SD card for text reader
// ---------------------------------------------------------------------------
// SD card is already initialized (early init above).
// Now set up SD-dependent features: message history + text reader.
// ---------------------------------------------------------------------------
#if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD)
{
pinMode(SDCARD_CS, OUTPUT);
digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially
if (SD.begin(SDCARD_CS, displaySpi, 4000000)) {
MESH_DEBUG_PRINTLN("setup() - SD card initialized");
// Tell the text reader that SD is ready, then pre-index books at boot
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
if (reader) {
reader->setSDReady(true);
if (disp) {
reader->bootIndex(*disp);
}
if (sdCardReady) {
// Load persisted channel messages from SD
ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen();
if (chanScr) {
chanScr->setSDReady(true);
if (chanScr->loadFromSD()) {
MESH_DEBUG_PRINTLN("setup() - Message history loaded from SD");
}
}
// Tell the text reader that SD is ready, then pre-index books at boot
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
if (reader) {
reader->setSDReady(true);
if (disp) {
reader->bootIndex(*disp);
}
}
// Do an initial settings backup to SD (captures any first-boot defaults)
backupSettingsToSD();
}
#endif
// ---------------------------------------------------------------------------
// First-boot onboarding detection
// Check if node name is still the default hex prefix (first 4 bytes of pub key)
// If so, launch onboarding wizard to set name and radio preset
// ---------------------------------------------------------------------------
#if defined(LilyGo_TDeck_Pro)
{
char defaultName[10];
mesh::Utils::toHex(defaultName, the_mesh.self_id.pub_key, 4);
NodePrefs* prefs = the_mesh.getNodePrefs();
if (strcmp(prefs->node_name, defaultName) == 0) {
MESH_DEBUG_PRINTLN("setup() - Default node name detected, launching onboarding");
ui_task.gotoOnboarding();
}
}
#endif
@@ -375,17 +532,10 @@ void setup() {
// Set GPS enabled in both sensor manager and node prefs
sensors.setSettingValue("gps", "1");
the_mesh.getNodePrefs()->gps_enabled = 1;
the_mesh.savePrefs();
the_mesh.savePrefs(); // SD backup triggered automatically
MESH_DEBUG_PRINTLN("setup() - GPS enabled by default");
#endif
// T-Deck Pro: BLE starts disabled for standalone-first operation
// User can toggle it on from the Bluetooth home page (Enter or long-press)
#if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE)
serial_interface.disable();
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
#endif
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
}
@@ -619,20 +769,28 @@ void handleKeyboardInput() {
return;
}
// C key: allow entering compose mode from reader
if (key == 'c' || key == 'C') {
composeDM = false;
composeDMContactIdx = -1;
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering compose mode from reader, channel %d\n", composeChannelIdx);
drawComposeScreen();
lastComposeRefresh = millis();
// All other keys pass through to the reader screen
ui_task.injectKey(key);
return;
}
// *** SETTINGS MODE ***
if (ui_task.isOnSettingsScreen()) {
SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen();
// Q key: exit settings (when not editing)
if (!settings->isEditing() && (key == 'q' || key == 'Q')) {
if (settings->hasRadioChanges()) {
// Let settings show "apply changes?" confirm dialog
ui_task.injectKey(key);
} else {
Serial.println("Exiting settings");
ui_task.gotoHomeScreen();
}
return;
}
// All other keys pass through to the reader screen
// All other keys → settings screen via injectKey (no forceRefresh)
ui_task.injectKey(key);
return;
}
@@ -641,38 +799,11 @@ void handleKeyboardInput() {
switch (key) {
case 'c':
case 'C':
// Enter compose mode - DM if on contacts screen, channel otherwise
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
composeDM = true;
composeDMContactIdx = idx;
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
drawComposeScreen();
lastComposeRefresh = millis();
}
} else {
composeDM = false;
composeDMContactIdx = -1;
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
// If on channel screen, sync compose channel with viewed channel
if (ui_task.isOnChannelScreen()) {
composeChannelIdx = ui_task.getChannelScreenViewIdx();
}
Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx);
drawComposeScreen();
lastComposeRefresh = millis();
}
// Open contacts list
Serial.println("Opening contacts");
ui_task.gotoContactsScreen();
break;
case 'm':
case 'M':
// Go to channel message screen
@@ -680,18 +811,22 @@ void handleKeyboardInput() {
ui_task.gotoChannelScreen();
break;
case 'r':
case 'R':
// Open text reader
case 'e':
case 'E':
// Open text reader (ebooks)
Serial.println("Opening text reader");
ui_task.gotoTextReader();
break;
case 'n':
case 'N':
// Open contacts list
Serial.println("Opening contacts");
ui_task.gotoContactsScreen();
case 's':
case 'S':
// Open settings (from home), or navigate down on channel/contacts
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
} else {
Serial.println("Opening settings");
ui_task.gotoSettingsScreen();
}
break;
case 'w':
@@ -705,17 +840,6 @@ void handleKeyboardInput() {
}
break;
case 's':
case 'S':
// Navigate down/next (scroll on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
ui_task.injectKey('s'); // Pass directly for channel/contacts switching
} else {
Serial.println("Nav: Next");
ui_task.injectKey(0xF1); // KEY_NEXT
}
break;
case 'a':
case 'A':
// Navigate left or switch channel (on channel screen)
@@ -739,7 +863,7 @@ void handleKeyboardInput() {
break;
case '\r':
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
// Enter = compose (only from channel or contacts screen)
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
int idx = cs->getSelectedContactIdx();
@@ -755,33 +879,30 @@ void handleKeyboardInput() {
drawComposeScreen();
lastComposeRefresh = millis();
} else if (idx >= 0) {
// Non-chat contact selected (repeater, room, etc.) - future use
Serial.printf("Selected non-chat contact type=%d idx=%d\n", ctype, idx);
}
} else if (ui_task.isOnChannelScreen()) {
composeDM = false;
composeDMContactIdx = -1;
composeChannelIdx = ui_task.getChannelScreenViewIdx();
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx);
drawComposeScreen();
lastComposeRefresh = millis();
} else {
Serial.println("Nav: Enter/Select");
ui_task.injectKey(13); // KEY_ENTER
// Other screens: pass Enter as generic select
ui_task.injectKey(13);
}
break;
case 'q':
case 'Q':
case '\b':
// If editing UTC offset on GPS page, pass through to cancel
if (ui_task.isEditingHomeScreen()) {
ui_task.injectKey('q');
} else {
// Go back to home screen
Serial.println("Nav: Back to home");
ui_task.gotoHomeScreen();
}
break;
case 'u':
case 'U':
// UTC offset editing (on GPS home page)
Serial.println("Nav: UTC offset");
ui_task.injectKey('u');
// Go back to home screen
Serial.println("Nav: Back to home");
ui_task.gotoHomeScreen();
break;
case ' ':

View File

@@ -6,14 +6,45 @@
#include <MeshCore.h>
#include "EmojiSprites.h"
// SD card message persistence
#if defined(HAS_SDCARD) && defined(ESP32)
#include <SD.h>
#endif
// Maximum messages to store in history
#define CHANNEL_MSG_HISTORY_SIZE 20
#define CHANNEL_MSG_HISTORY_SIZE 300
#define CHANNEL_MSG_TEXT_LEN 160
#ifndef MAX_GROUP_CHANNELS
#define MAX_GROUP_CHANNELS 20
#endif
// ---------------------------------------------------------------------------
// On-disk format for message persistence (SD card)
// ---------------------------------------------------------------------------
#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store
#define MSG_FILE_VERSION 1
#define MSG_FILE_PATH "/meshcore/messages.bin"
struct __attribute__((packed)) MsgFileHeader {
uint32_t magic;
uint16_t version;
uint16_t capacity;
uint16_t count;
int16_t newestIdx;
// 12 bytes total
};
struct __attribute__((packed)) MsgFileRecord {
uint32_t timestamp;
uint8_t path_len;
uint8_t channel_idx;
uint8_t valid;
uint8_t reserved;
char text[CHANNEL_MSG_TEXT_LEN];
// 168 bytes total
};
class UITask; // Forward declaration
class MyMesh; // Forward declaration
extern MyMesh the_mesh;
@@ -38,17 +69,20 @@ private:
int _scrollPos; // Current scroll position (0 = newest)
int _msgsPerPage; // Messages that fit on screen
uint8_t _viewChannelIdx; // Which channel we're currently viewing
bool _sdReady; // SD card is available for persistence
public:
ChannelScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0),
_msgsPerPage(3), _viewChannelIdx(0) {
_msgsPerPage(CHANNEL_MSG_HISTORY_SIZE), _viewChannelIdx(0), _sdReady(false) {
// Initialize all messages as invalid
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
_messages[i].valid = false;
}
}
void setSDReady(bool ready) { _sdReady = ready; }
// Add a new message to the history
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text) {
// Move to next slot in circular buffer
@@ -70,6 +104,9 @@ public:
// Reset scroll to show newest message
_scrollPos = 0;
// Persist to SD card
saveToSD();
}
// Get count of messages for the currently viewed channel
@@ -88,6 +125,135 @@ public:
uint8_t getViewChannelIdx() const { return _viewChannelIdx; }
void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; }
// -----------------------------------------------------------------------
// SD card persistence
// -----------------------------------------------------------------------
// Save the entire message buffer to SD card.
// File: /meshcore/messages.bin (~50 KB for 300 messages)
void saveToSD() {
#if defined(HAS_SDCARD) && defined(ESP32)
if (!_sdReady) return;
// Ensure directory exists
if (!SD.exists("/meshcore")) {
SD.mkdir("/meshcore");
}
File f = SD.open(MSG_FILE_PATH, "w", true);
if (!f) {
Serial.println("ChannelScreen: SD save failed - can't open file");
return;
}
// Write header
MsgFileHeader hdr;
hdr.magic = MSG_FILE_MAGIC;
hdr.version = MSG_FILE_VERSION;
hdr.capacity = CHANNEL_MSG_HISTORY_SIZE;
hdr.count = (uint16_t)_msgCount;
hdr.newestIdx = (int16_t)_newestIdx;
f.write((uint8_t*)&hdr, sizeof(hdr));
// Write all message slots (including invalid ones - preserves circular buffer layout)
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
MsgFileRecord rec;
rec.timestamp = _messages[i].timestamp;
rec.path_len = _messages[i].path_len;
rec.channel_idx = _messages[i].channel_idx;
rec.valid = _messages[i].valid ? 1 : 0;
rec.reserved = 0;
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
f.write((uint8_t*)&rec, sizeof(rec));
}
f.close();
digitalWrite(SDCARD_CS, HIGH); // Release SD CS
#endif
}
// Load message buffer from SD card. Returns true if messages were loaded.
bool loadFromSD() {
#if defined(HAS_SDCARD) && defined(ESP32)
if (!_sdReady) return false;
if (!SD.exists(MSG_FILE_PATH)) {
Serial.println("ChannelScreen: No saved messages on SD");
return false;
}
File f = SD.open(MSG_FILE_PATH, "r");
if (!f) {
Serial.println("ChannelScreen: SD load failed - can't open file");
return false;
}
// Read and validate header
MsgFileHeader hdr;
if (f.read((uint8_t*)&hdr, sizeof(hdr)) != sizeof(hdr)) {
Serial.println("ChannelScreen: SD load failed - short header");
f.close();
return false;
}
if (hdr.magic != MSG_FILE_MAGIC) {
Serial.printf("ChannelScreen: SD load failed - bad magic 0x%08X\n", hdr.magic);
f.close();
return false;
}
if (hdr.version != MSG_FILE_VERSION) {
Serial.printf("ChannelScreen: SD load failed - version %d (expected %d)\n",
hdr.version, MSG_FILE_VERSION);
f.close();
return false;
}
if (hdr.capacity != CHANNEL_MSG_HISTORY_SIZE) {
Serial.printf("ChannelScreen: SD load failed - capacity %d (expected %d)\n",
hdr.capacity, CHANNEL_MSG_HISTORY_SIZE);
f.close();
return false;
}
// Read message records
int loaded = 0;
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
MsgFileRecord rec;
if (f.read((uint8_t*)&rec, sizeof(rec)) != sizeof(rec)) {
Serial.printf("ChannelScreen: SD load - short read at record %d\n", i);
break;
}
_messages[i].timestamp = rec.timestamp;
_messages[i].path_len = rec.path_len;
_messages[i].channel_idx = rec.channel_idx;
_messages[i].valid = (rec.valid != 0);
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
if (_messages[i].valid) loaded++;
}
_msgCount = (int)hdr.count;
_newestIdx = (int)hdr.newestIdx;
_scrollPos = 0;
// Sanity-check restored state
if (_newestIdx < -1 || _newestIdx >= CHANNEL_MSG_HISTORY_SIZE) _newestIdx = -1;
if (_msgCount < 0 || _msgCount > CHANNEL_MSG_HISTORY_SIZE) _msgCount = loaded;
f.close();
digitalWrite(SDCARD_CS, HIGH); // Release SD CS
Serial.printf("ChannelScreen: Loaded %d messages from SD (count=%d, newest=%d)\n",
loaded, _msgCount, _newestIdx);
return loaded > 0;
#else
return false;
#endif
}
// -----------------------------------------------------------------------
// Rendering
// -----------------------------------------------------------------------
int render(DisplayDriver& display) override {
char tmp[40];
@@ -161,6 +327,7 @@ public:
// Display messages oldest-to-newest (top to bottom)
int msgsDrawn = 0;
bool screenFull = false;
for (int i = startIdx; i < numChannelMsgs && y + lineHeight <= maxY; i++) {
int idx = channelMsgs[i];
ChannelMessage* msg = &_messages[idx];
@@ -290,6 +457,13 @@ public:
y += 2; // Small gap between messages
msgsDrawn++;
if (y + lineHeight > maxY) screenFull = true;
}
// Only update _msgsPerPage when the screen actually filled up.
// If we ran out of messages before filling the screen, keep the
// previous (higher) value so startIdx doesn't under-count.
if (screenFull && msgsDrawn > 0) {
_msgsPerPage = msgsDrawn;
}
@@ -305,8 +479,8 @@ public:
// Left side: Q:Back A/D:Ch
display.print("Q:Back A/D:Ch");
// Right side: C:New
const char* rightText = "C:New";
// Right side: Entr:New
const char* rightText = "Entr:New";
display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY);
display.print(rightText);

View File

@@ -88,7 +88,7 @@ private:
}
}
// Sort by last_advert_timestamp descending (most recently seen first)
// Simple insertion sort fine for up to 400 entries on ESP32
// Simple insertion sort — fine for up to 400 entries on ESP32
for (int i = 1; i < _filteredCount; i++) {
uint16_t tmpIdx = _filteredIdx[i];
uint32_t tmpTs = _filteredTs[i];
@@ -180,8 +180,12 @@ public:
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
display.print(tmp);
// Count on right
snprintf(tmp, sizeof(tmp), "%d/%d", _filteredCount, (int)the_mesh.getNumContacts());
// Count on right: All → total/max, filtered → matched/total
if (_filter == FILTER_ALL) {
snprintf(tmp, sizeof(tmp), "%d/%d", (int)the_mesh.getNumContacts(), MAX_CONTACTS);
} else {
snprintf(tmp, sizeof(tmp), "%d/%d", _filteredCount, (int)the_mesh.getNumContacts());
}
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);

View File

@@ -417,7 +417,7 @@ static const EmojiCodepoint EMOJI_CODEPOINTS[EMOJI_COUNT] = {
{ 0x1F506, 0x0000, 0xA2 }, // bright
{ 0x303D, 0x0000, 0xA3 }, // part_alt
{ 0x1F6E5, 0x0000, 0xA4 }, // motorboat
{ 0x1F0CE, 0x0000, 0xA5 }, // domino
{ 0x1F030, 0x0000, 0xA5 }, // domino
{ 0x1F4E1, 0x0000, 0xA6 }, // satellite
{ 0x1F6C3, 0x0000, 0xA7 }, // customs
{ 0x1F920, 0x0000, 0xA8 }, // cowboy

View File

@@ -0,0 +1,849 @@
#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ChannelDetails.h>
#include <MeshCore.h>
#include "../NodePrefs.h"
// Forward declarations
class UITask;
class MyMesh;
extern MyMesh the_mesh;
// ---------------------------------------------------------------------------
// Radio presets
// ---------------------------------------------------------------------------
struct RadioPreset {
const char* name;
float freq;
float bw;
uint8_t sf;
uint8_t cr;
uint8_t tx_power;
};
static const RadioPreset RADIO_PRESETS[] = {
{ "MeshCore Default", 915.0f, 250.0f, 10, 5, 20 },
{ "Long Range", 915.0f, 125.0f, 12, 8, 20 },
{ "Fast/Short", 915.0f, 500.0f, 7, 5, 20 },
{ "EU Default", 869.4f, 250.0f, 10, 5, 14 },
};
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))
// ---------------------------------------------------------------------------
// Settings row types
// ---------------------------------------------------------------------------
enum SettingsRowType : uint8_t {
ROW_NAME, // Device name (text editor)
ROW_RADIO_PRESET, // Radio preset picker
ROW_FREQ, // Frequency (float)
ROW_BW, // Bandwidth (float)
ROW_SF, // Spreading factor (5-12)
ROW_CR, // Coding rate (5-8)
ROW_TX_POWER, // TX power (1-20 dBm)
ROW_UTC_OFFSET, // UTC offset (-12 to +14)
ROW_CH_HEADER, // "--- Channels ---" separator
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
ROW_INFO_HEADER, // "--- Info ---" separator
ROW_PUB_KEY, // Public key display
ROW_FIRMWARE, // Firmware version
};
// ---------------------------------------------------------------------------
// Editing modes
// ---------------------------------------------------------------------------
enum EditMode : uint8_t {
EDIT_NONE, // Just browsing
EDIT_TEXT, // Typing into a text buffer (name, channel name)
EDIT_PICKER, // A/D cycles options (radio preset)
EDIT_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC)
EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio)
};
// Max rows in the settings list
#define SETTINGS_MAX_ROWS 40
#define SETTINGS_TEXT_BUF 33 // 32 chars + null
class SettingsScreen : public UIScreen {
private:
UITask* _task;
mesh::RTCClock* _rtc;
NodePrefs* _prefs;
// Row table — rebuilt whenever channels change
struct Row {
SettingsRowType type;
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
};
Row _rows[SETTINGS_MAX_ROWS];
int _numRows;
// Cursor & scroll
int _cursor; // selected row
int _scrollTop; // first visible row
// Editing state
EditMode _editMode;
char _editBuf[SETTINGS_TEXT_BUF];
int _editPos;
int _editPickerIdx; // for preset picker
float _editFloat; // for freq/BW editing
int _editInt; // for SF/CR/TX/UTC editing
int _confirmAction; // 0=none, 1=delete channel, 2=apply radio
// Onboarding mode
bool _onboarding;
// Dirty flag for radio params — prompt to apply
bool _radioChanged;
// ---------------------------------------------------------------------------
// Row table management
// ---------------------------------------------------------------------------
void rebuildRows() {
_numRows = 0;
addRow(ROW_NAME);
addRow(ROW_RADIO_PRESET);
addRow(ROW_FREQ);
addRow(ROW_BW);
addRow(ROW_SF);
addRow(ROW_CR);
addRow(ROW_TX_POWER);
addRow(ROW_UTC_OFFSET);
addRow(ROW_CH_HEADER);
// Enumerate current channels
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
addRow(ROW_CHANNEL, i);
} else {
break; // channels are contiguous
}
}
addRow(ROW_ADD_CHANNEL);
addRow(ROW_INFO_HEADER);
addRow(ROW_PUB_KEY);
addRow(ROW_FIRMWARE);
// Clamp cursor
if (_cursor >= _numRows) _cursor = _numRows - 1;
if (_cursor < 0) _cursor = 0;
skipNonSelectable(1);
}
void addRow(SettingsRowType type, uint8_t param = 0) {
if (_numRows < SETTINGS_MAX_ROWS) {
_rows[_numRows].type = type;
_rows[_numRows].param = param;
_numRows++;
}
}
bool isSelectable(int idx) const {
if (idx < 0 || idx >= _numRows) return false;
SettingsRowType t = _rows[idx].type;
return t != ROW_CH_HEADER && t != ROW_INFO_HEADER;
}
void skipNonSelectable(int dir) {
while (_cursor >= 0 && _cursor < _numRows && !isSelectable(_cursor)) {
_cursor += dir;
}
if (_cursor < 0) _cursor = 0;
if (_cursor >= _numRows) _cursor = _numRows - 1;
}
// ---------------------------------------------------------------------------
// Radio preset detection
// ---------------------------------------------------------------------------
int detectCurrentPreset() const {
for (int i = 0; i < (int)NUM_RADIO_PRESETS; i++) {
const RadioPreset& p = RADIO_PRESETS[i];
if (fabsf(_prefs->freq - p.freq) < 0.01f &&
fabsf(_prefs->bw - p.bw) < 0.01f &&
_prefs->sf == p.sf &&
_prefs->cr == p.cr &&
_prefs->tx_power_dbm == p.tx_power) {
return i;
}
}
return -1; // Custom
}
// ---------------------------------------------------------------------------
// Hashtag channel creation
// ---------------------------------------------------------------------------
void createHashtagChannel(const char* name) {
// Build channel name with # prefix if not already present
char chanName[32];
if (name[0] == '#') {
strncpy(chanName, name, sizeof(chanName));
} else {
chanName[0] = '#';
strncpy(&chanName[1], name, sizeof(chanName) - 1);
}
chanName[31] = '\0';
// Generate 128-bit PSK from SHA-256 of channel name
ChannelDetails newCh;
memset(&newCh, 0, sizeof(newCh));
strncpy(newCh.name, chanName, sizeof(newCh.name));
newCh.name[31] = '\0';
// SHA-256 the channel name → first 16 bytes become the secret
uint8_t hash[32];
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
memcpy(newCh.channel.secret, hash, 16);
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
// Find next empty slot
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails existing;
if (!the_mesh.getChannel(i, existing) || existing.name[0] == '\0') {
if (the_mesh.setChannel(i, newCh)) {
the_mesh.saveChannels();
Serial.printf("Settings: Created hashtag channel '%s' at idx %d\n", chanName, i);
}
break;
}
}
}
void deleteChannel(uint8_t idx) {
// Clear the channel by writing an empty ChannelDetails
// Then compact: shift all channels above it down by one
ChannelDetails empty;
memset(&empty, 0, sizeof(empty));
// Find total channel count
int total = 0;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
total = i + 1;
} else {
break;
}
}
// Shift channels down
for (int i = idx; i < total - 1; i++) {
ChannelDetails next;
if (the_mesh.getChannel(i + 1, next)) {
the_mesh.setChannel(i, next);
}
}
// Clear the last slot
the_mesh.setChannel(total - 1, empty);
the_mesh.saveChannels();
Serial.printf("Settings: Deleted channel at idx %d, compacted %d channels\n", idx, total);
}
// ---------------------------------------------------------------------------
// Apply radio parameters live
// ---------------------------------------------------------------------------
void applyRadioParams() {
radio_set_params(_prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr);
radio_set_tx_power(_prefs->tx_power_dbm);
the_mesh.savePrefs();
_radioChanged = false;
Serial.printf("Settings: Radio params applied - %.3f/%g/%d/%d TX:%d\n",
_prefs->freq, _prefs->bw, _prefs->sf, _prefs->cr, _prefs->tx_power_dbm);
}
public:
SettingsScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* prefs)
: _task(task), _rtc(rtc), _prefs(prefs),
_numRows(0), _cursor(0), _scrollTop(0),
_editMode(EDIT_NONE), _editPos(0), _editPickerIdx(0),
_editFloat(0), _editInt(0), _confirmAction(0),
_onboarding(false), _radioChanged(false) {
memset(_editBuf, 0, sizeof(_editBuf));
}
void enter() {
_editMode = EDIT_NONE;
_cursor = 0;
_scrollTop = 0;
_radioChanged = false;
rebuildRows();
}
void enterOnboarding() {
enter();
_onboarding = true;
// Start editing the device name immediately
_cursor = 0; // ROW_NAME
startEditText(_prefs->node_name);
}
bool isOnboarding() const { return _onboarding; }
bool isEditing() const { return _editMode != EDIT_NONE; }
bool hasRadioChanges() const { return _radioChanged; }
// ---------------------------------------------------------------------------
// Edit mode starters
// ---------------------------------------------------------------------------
void startEditText(const char* initial) {
_editMode = EDIT_TEXT;
strncpy(_editBuf, initial, SETTINGS_TEXT_BUF - 1);
_editBuf[SETTINGS_TEXT_BUF - 1] = '\0';
_editPos = strlen(_editBuf);
}
void startEditPicker(int initialIdx) {
_editMode = EDIT_PICKER;
_editPickerIdx = initialIdx;
}
void startEditFloat(float initial) {
_editMode = EDIT_NUMBER;
_editFloat = initial;
}
void startEditInt(int initial) {
_editMode = EDIT_NUMBER;
_editInt = initial;
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
int render(DisplayDriver& display) override {
char tmp[64];
// === Header ===
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
if (_onboarding) {
display.print("Welcome! Setup");
} else {
display.print("Settings");
}
// Right side: row indicator
snprintf(tmp, sizeof(tmp), "%d/%d", _cursor + 1, _numRows);
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);
display.drawRect(0, 11, display.width(), 1);
// === Body ===
display.setTextSize(0); // tiny font
int lineHeight = 9;
int headerH = 14;
int footerH = 14;
int maxY = display.height() - footerH;
// Center scroll window around cursor
int maxVisible = (maxY - headerH) / lineHeight;
if (maxVisible < 3) maxVisible = 3;
_scrollTop = max(0, min(_cursor - maxVisible / 2, _numRows - maxVisible));
int endIdx = min(_numRows, _scrollTop + maxVisible);
int y = headerH;
for (int i = _scrollTop; i < endIdx && y + lineHeight <= maxY; i++) {
bool selected = (i == _cursor);
bool editing = selected && (_editMode != EDIT_NONE);
// Selection highlight
if (selected) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y + 5, display.width(), lineHeight);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
switch (_rows[i].type) {
case ROW_NAME:
if (editing && _editMode == EDIT_TEXT) {
snprintf(tmp, sizeof(tmp), "Name: %s_", _editBuf);
} else {
snprintf(tmp, sizeof(tmp), "Name: %s", _prefs->node_name);
}
display.print(tmp);
break;
case ROW_RADIO_PRESET: {
int preset = detectCurrentPreset();
if (editing && _editMode == EDIT_PICKER) {
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
snprintf(tmp, sizeof(tmp), "< %s >", RADIO_PRESETS[_editPickerIdx].name);
} else {
strcpy(tmp, "< Custom >");
}
} else {
if (preset >= 0) {
snprintf(tmp, sizeof(tmp), "Preset: %s", RADIO_PRESETS[preset].name);
} else {
strcpy(tmp, "Preset: Custom");
}
}
display.print(tmp);
break;
}
case ROW_FREQ:
if (editing && _editMode == EDIT_NUMBER) {
snprintf(tmp, sizeof(tmp), "Freq: %.3f <W/S>", _editFloat);
} else {
snprintf(tmp, sizeof(tmp), "Freq: %.3f MHz", _prefs->freq);
}
display.print(tmp);
break;
case ROW_BW:
if (editing && _editMode == EDIT_NUMBER) {
snprintf(tmp, sizeof(tmp), "BW: %.1f <W/S>", _editFloat);
} else {
snprintf(tmp, sizeof(tmp), "BW: %.1f kHz", _prefs->bw);
}
display.print(tmp);
break;
case ROW_SF:
if (editing && _editMode == EDIT_NUMBER) {
snprintf(tmp, sizeof(tmp), "SF: %d <W/S>", _editInt);
} else {
snprintf(tmp, sizeof(tmp), "SF: %d", _prefs->sf);
}
display.print(tmp);
break;
case ROW_CR:
if (editing && _editMode == EDIT_NUMBER) {
snprintf(tmp, sizeof(tmp), "CR: %d <W/S>", _editInt);
} else {
snprintf(tmp, sizeof(tmp), "CR: %d", _prefs->cr);
}
display.print(tmp);
break;
case ROW_TX_POWER:
if (editing && _editMode == EDIT_NUMBER) {
snprintf(tmp, sizeof(tmp), "TX: %d dBm <W/S>", _editInt);
} else {
snprintf(tmp, sizeof(tmp), "TX: %d dBm", _prefs->tx_power_dbm);
}
display.print(tmp);
break;
case ROW_UTC_OFFSET:
if (editing && _editMode == EDIT_NUMBER) {
snprintf(tmp, sizeof(tmp), "UTC: %+d <W/S>", _editInt);
} else {
snprintf(tmp, sizeof(tmp), "UTC Offset: %+d", _prefs->utc_offset_hours);
}
display.print(tmp);
break;
case ROW_CH_HEADER:
display.setColor(DisplayDriver::YELLOW);
display.print("--- Channels ---");
break;
case ROW_CHANNEL: {
uint8_t chIdx = _rows[i].param;
ChannelDetails ch;
if (the_mesh.getChannel(chIdx, ch)) {
if (chIdx == 0) {
// Public channel - not deletable
snprintf(tmp, sizeof(tmp), " %s", ch.name);
} else {
snprintf(tmp, sizeof(tmp), " %s", ch.name);
if (selected) {
// Show delete hint on right
const char* hint = "Del:X";
int hintW = display.getTextWidth(hint);
display.setCursor(display.width() - hintW - 2, y);
display.print(hint);
display.setCursor(0, y);
}
}
} else {
snprintf(tmp, sizeof(tmp), " (empty)");
}
display.print(tmp);
break;
}
case ROW_ADD_CHANNEL:
if (editing && _editMode == EDIT_TEXT) {
snprintf(tmp, sizeof(tmp), "# %s_", _editBuf);
} else {
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
strcpy(tmp, "+ Add Hashtag Channel");
}
display.print(tmp);
break;
case ROW_INFO_HEADER:
display.setColor(DisplayDriver::YELLOW);
display.print("--- Device Info ---");
break;
case ROW_PUB_KEY: {
// Show first 8 bytes of pub key as hex (16 chars)
char hexBuf[17];
mesh::Utils::toHex(hexBuf, the_mesh.self_id.pub_key, 8);
snprintf(tmp, sizeof(tmp), "ID: %s", hexBuf);
display.print(tmp);
break;
}
case ROW_FIRMWARE:
snprintf(tmp, sizeof(tmp), "FW: %s", FIRMWARE_VERSION);
display.print(tmp);
break;
}
y += lineHeight;
}
display.setTextSize(1);
// === Confirmation overlay ===
if (_editMode == EDIT_CONFIRM) {
int bx = 4, by = 30, bw = display.width() - 8, bh = 36;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(0);
if (_confirmAction == 1) {
uint8_t chIdx = _rows[_cursor].param;
ChannelDetails ch;
the_mesh.getChannel(chIdx, ch);
snprintf(tmp, sizeof(tmp), "Delete %s?", ch.name);
display.drawTextCentered(display.width() / 2, by + 4, tmp);
} else if (_confirmAction == 2) {
display.drawTextCentered(display.width() / 2, by + 4, "Apply radio changes?");
}
display.drawTextCentered(display.width() / 2, by + bh - 14, "Enter:Yes Q:No");
display.setTextSize(1);
}
// === Footer ===
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
if (_editMode == EDIT_TEXT) {
display.print("Type, Enter:Ok Q:Cancel");
} else if (_editMode == EDIT_PICKER) {
display.print("A/D:Choose Enter:Ok");
} else if (_editMode == EDIT_NUMBER) {
display.print("W/S:Adj Enter:Ok Q:Cancel");
} else if (_editMode == EDIT_CONFIRM) {
// Footer already covered by overlay
} else {
display.print("Q:Bck");
const char* r = "W/S:Up/Dwn Entr:Chng";
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
display.print(r);
}
return _editMode != EDIT_NONE ? 700 : 1000;
}
// ---------------------------------------------------------------------------
// Input handling
// ---------------------------------------------------------------------------
// Handle a keyboard character. Returns true if the screen consumed the input.
bool handleKeyInput(char c) {
// --- Confirmation dialog ---
if (_editMode == EDIT_CONFIRM) {
if (c == '\r' || c == 13) {
if (_confirmAction == 1) {
// Delete channel
uint8_t chIdx = _rows[_cursor].param;
deleteChannel(chIdx);
rebuildRows();
} else if (_confirmAction == 2) {
applyRadioParams();
}
_editMode = EDIT_NONE;
_confirmAction = 0;
return true;
}
if (c == 'q' || c == 'Q') {
_editMode = EDIT_NONE;
_confirmAction = 0;
return true;
}
return true; // consume all keys in confirm mode
}
// --- Text editing mode ---
if (_editMode == EDIT_TEXT) {
if (c == '\r' || c == 13) {
// Confirm text edit
SettingsRowType type = _rows[_cursor].type;
if (type == ROW_NAME) {
if (_editPos > 0) {
strncpy(_prefs->node_name, _editBuf, sizeof(_prefs->node_name));
_prefs->node_name[31] = '\0';
the_mesh.savePrefs();
Serial.printf("Settings: Name set to '%s'\n", _prefs->node_name);
}
_editMode = EDIT_NONE;
if (_onboarding) {
// Move to radio preset selection
_cursor = 1; // ROW_RADIO_PRESET
startEditPicker(max(0, detectCurrentPreset()));
}
} else if (type == ROW_ADD_CHANNEL) {
if (_editPos > 0) {
createHashtagChannel(_editBuf);
rebuildRows();
}
_editMode = EDIT_NONE;
}
return true;
}
if (c == 'q' || c == 'Q' || c == 27) {
_editMode = EDIT_NONE;
return true;
}
if (c == '\b') {
if (_editPos > 0) {
_editPos--;
_editBuf[_editPos] = '\0';
}
return true;
}
// Printable character
if (c >= 32 && c < 127 && _editPos < SETTINGS_TEXT_BUF - 1) {
_editBuf[_editPos++] = c;
_editBuf[_editPos] = '\0';
return true;
}
return true; // consume all keys in text edit
}
// --- Picker mode (radio preset) ---
if (_editMode == EDIT_PICKER) {
if (c == 'a' || c == 'A') {
_editPickerIdx--;
if (_editPickerIdx < 0) _editPickerIdx = (int)NUM_RADIO_PRESETS - 1;
return true;
}
if (c == 'd' || c == 'D') {
_editPickerIdx++;
if (_editPickerIdx >= (int)NUM_RADIO_PRESETS) _editPickerIdx = 0;
return true;
}
if (c == '\r' || c == 13) {
// Apply preset
if (_editPickerIdx >= 0 && _editPickerIdx < (int)NUM_RADIO_PRESETS) {
const RadioPreset& p = RADIO_PRESETS[_editPickerIdx];
_prefs->freq = p.freq;
_prefs->bw = p.bw;
_prefs->sf = p.sf;
_prefs->cr = p.cr;
_prefs->tx_power_dbm = p.tx_power;
_radioChanged = true;
}
_editMode = EDIT_NONE;
if (_onboarding) {
// Apply and finish onboarding
applyRadioParams();
_onboarding = false;
}
return true;
}
if (c == 'q' || c == 'Q') {
_editMode = EDIT_NONE;
return true;
}
return true;
}
// --- Number editing mode ---
if (_editMode == EDIT_NUMBER) {
SettingsRowType type = _rows[_cursor].type;
if (c == 'w' || c == 'W') {
switch (type) {
case ROW_FREQ: _editFloat += 0.1f; break;
case ROW_BW:
// Cycle through common bandwidths
if (_editFloat < 31.25f) _editFloat = 31.25f;
else if (_editFloat < 62.5f) _editFloat = 62.5f;
else if (_editFloat < 125.0f) _editFloat = 125.0f;
else if (_editFloat < 250.0f) _editFloat = 250.0f;
else _editFloat = 500.0f;
break;
case ROW_SF: if (_editInt < 12) _editInt++; break;
case ROW_CR: if (_editInt < 8) _editInt++; break;
case ROW_TX_POWER: if (_editInt < MAX_LORA_TX_POWER) _editInt++; break;
case ROW_UTC_OFFSET: if (_editInt < 14) _editInt++; break;
default: break;
}
return true;
}
if (c == 's' || c == 'S') {
switch (type) {
case ROW_FREQ: _editFloat -= 0.1f; break;
case ROW_BW:
if (_editFloat > 250.0f) _editFloat = 250.0f;
else if (_editFloat > 125.0f) _editFloat = 125.0f;
else if (_editFloat > 62.5f) _editFloat = 62.5f;
else _editFloat = 31.25f;
break;
case ROW_SF: if (_editInt > 5) _editInt--; break;
case ROW_CR: if (_editInt > 5) _editInt--; break;
case ROW_TX_POWER: if (_editInt > 1) _editInt--; break;
case ROW_UTC_OFFSET: if (_editInt > -12) _editInt--; break;
default: break;
}
return true;
}
if (c == '\r' || c == 13) {
// Confirm number edit
switch (type) {
case ROW_FREQ:
_prefs->freq = constrain(_editFloat, 400.0f, 2500.0f);
_radioChanged = true;
break;
case ROW_BW:
_prefs->bw = _editFloat;
_radioChanged = true;
break;
case ROW_SF:
_prefs->sf = (uint8_t)constrain(_editInt, 5, 12);
_radioChanged = true;
break;
case ROW_CR:
_prefs->cr = (uint8_t)constrain(_editInt, 5, 8);
_radioChanged = true;
break;
case ROW_TX_POWER:
_prefs->tx_power_dbm = (uint8_t)constrain(_editInt, 1, MAX_LORA_TX_POWER);
_radioChanged = true;
break;
case ROW_UTC_OFFSET:
_prefs->utc_offset_hours = (int8_t)constrain(_editInt, -12, 14);
the_mesh.savePrefs();
break;
default: break;
}
_editMode = EDIT_NONE;
return true;
}
if (c == 'q' || c == 'Q') {
_editMode = EDIT_NONE;
return true;
}
return true;
}
// --- Normal browsing mode ---
// W/S: navigate
if (c == 'w' || c == 'W') {
if (_cursor > 0) {
_cursor--;
skipNonSelectable(-1);
}
Serial.printf("Settings: cursor=%d/%d row=%d\n", _cursor, _numRows, _rows[_cursor].type);
return true;
}
if (c == 's' || c == 'S') {
if (_cursor < _numRows - 1) {
_cursor++;
skipNonSelectable(1);
}
Serial.printf("Settings: cursor=%d/%d row=%d\n", _cursor, _numRows, _rows[_cursor].type);
return true;
}
// Enter: start editing the selected row
if (c == '\r' || c == 13) {
SettingsRowType type = _rows[_cursor].type;
switch (type) {
case ROW_NAME:
startEditText(_prefs->node_name);
break;
case ROW_RADIO_PRESET:
startEditPicker(max(0, detectCurrentPreset()));
break;
case ROW_FREQ:
startEditFloat(_prefs->freq);
break;
case ROW_BW:
startEditFloat(_prefs->bw);
break;
case ROW_SF:
startEditInt(_prefs->sf);
break;
case ROW_CR:
startEditInt(_prefs->cr);
break;
case ROW_TX_POWER:
startEditInt(_prefs->tx_power_dbm);
break;
case ROW_UTC_OFFSET:
startEditInt(_prefs->utc_offset_hours);
break;
case ROW_ADD_CHANNEL:
startEditText("");
break;
case ROW_CHANNEL:
case ROW_PUB_KEY:
case ROW_FIRMWARE:
// Not directly editable on Enter
break;
default:
break;
}
return true;
}
// X: delete channel (when on a channel row, idx > 0)
if (c == 'x' || c == 'X') {
if (_rows[_cursor].type == ROW_CHANNEL && _rows[_cursor].param > 0) {
_editMode = EDIT_CONFIRM;
_confirmAction = 1;
return true;
}
}
// Q: back — if radio changed, prompt to apply first
if (c == 'q' || c == 'Q') {
if (_radioChanged) {
_editMode = EDIT_CONFIRM;
_confirmAction = 2;
return true;
}
_onboarding = false;
return false; // Let the caller handle navigation back
}
return true; // Consume all other keys (don't let caller exit)
}
// Override handleInput for UIScreen compatibility (used by injectKey)
bool handleInput(char c) override {
return handleKeyInput(c);
}
};

View File

@@ -33,6 +33,7 @@
#include "ChannelScreen.h"
#include "ContactsScreen.h"
#include "TextReaderScreen.h"
#include "SettingsScreen.h"
class SplashScreen : public UIScreen {
UITask* _task;
@@ -716,6 +717,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
channel_screen = new ChannelScreen(this, &rtc_clock);
contacts_screen = new ContactsScreen(this, &rtc_clock);
text_reader = new TextReaderScreen(this);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
setCurrScreen(splash);
}
@@ -1155,6 +1157,26 @@ void UITask::gotoTextReader() {
_next_refresh = 100;
}
void UITask::gotoSettingsScreen() {
((SettingsScreen*)settings_screen)->enter();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoOnboarding() {
((SettingsScreen*)settings_screen)->enterOnboarding();
setCurrScreen(settings_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
uint8_t UITask::getChannelScreenViewIdx() const {
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
}

View File

@@ -54,6 +54,7 @@ class UITask : public AbstractUITask {
UIScreen* channel_screen; // Channel message history screen
UIScreen* contacts_screen; // Contacts list screen
UIScreen* text_reader; // *** NEW: Text reader screen ***
UIScreen* settings_screen; // Settings/onboarding screen
UIScreen* curr;
void userLedHandler();
@@ -79,6 +80,8 @@ public:
void gotoChannelScreen(); // Navigate to channel message screen
void gotoContactsScreen(); // Navigate to contacts list
void gotoTextReader(); // *** NEW: Navigate to text reader ***
void gotoSettingsScreen(); // Navigate to settings
void gotoOnboarding(); // Navigate to settings in onboarding mode
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
int getMsgCount() const { return _msgcount; }
@@ -87,13 +90,16 @@ public:
bool isOnChannelScreen() const { return curr == channel_screen; }
bool isOnContactsScreen() const { return curr == contacts_screen; }
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
bool isEditingHomeScreen() const; // UTC offset editing on GPS page
bool isOnSettingsScreen() const { return curr == settings_screen; }
uint8_t getChannelScreenViewIdx() const;
void toggleBuzzer();
bool getGPSState();
void toggleGPS();
// Check if home screen is in an editing mode (e.g. UTC offset editor)
bool isEditingHomeScreen() const;
// Inject a key press from external source (e.g., keyboard)
void injectKey(char c);
@@ -105,6 +111,8 @@ public:
UIScreen* getMsgPreviewScreen() const { return msg_preview; }
UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW ***
UIScreen* getContactsScreen() const { return contacts_screen; }
UIScreen* getChannelScreen() const { return channel_screen; }
UIScreen* getSettingsScreen() const { return settings_screen; }
// from AbstractUITask
void msgRead(int msgcount) override;

File diff suppressed because it is too large Load Diff