1
0
forked from iarv/Meck

Compare commits

...

63 Commits

Author SHA1 Message Date
pelgraine
15fcb08441 changed wording of light flash on off to make it slightly clearer 2026-02-17 20:12:04 +11:00
pelgraine
48863a13aa update firmware version and date 2026-02-17 19:59:02 +11:00
pelgraine
98addc5bd3 update uitasks to enable keyboard pulse light notification 2026-02-17 19:54:39 +11:00
pelgraine
a10ce4e7ca update settingscreen to enable keyboard pulse light 2026-02-17 19:51:36 +11:00
pelgraine
dcb59f2e33 update node prefs to enable keyboard pulse light 2026-02-17 19:50:31 +11:00
pelgraine
9e40dc7f30 updated firmware version and date 2026-02-17 18:59:00 +11:00
pelgraine
5ed1edc287 same changes as in audio branch - note to self, files are the same and are ready for branch merging 2026-02-17 18:58:09 +11:00
pelgraine
ba057c8aad 45 m sleep timer; track queing from sub folders; better wav file name data extraction; changed autorefresh while playing to 30 seconds 2026-02-16 18:39:24 +11:00
pelgraine
8e91327230 msgread fix and newmsg alert suppresion when in repeater admin login page 2026-02-16 17:57:50 +11:00
pelgraine
7444a34e5a amended firmware version to align with other env variants 2026-02-15 19:37:01 +11:00
pelgraine
17bad1ddf7 using different HAS_BQ27220 for renderbatteryindicator 2026-02-15 19:35:34 +11:00
pelgraine
a4c86d2d31 battery charge estimated runtime fix - 2 to 3 charge discharge cycles needed for full calibration to 1400mah 2026-02-15 09:31:43 +11:00
pelgraine
8dd661246a battery gauge page and fixes 2026-02-15 07:16:51 +11:00
pelgraine
f07032182e fixed errant connected home display 2026-02-15 05:35:02 +11:00
pelgraine
1d39d9f314 ui fix 2026-02-15 02:21:27 +11:00
pelgraine
a4285d2f24 bumped max contacts up to 700 2026-02-15 02:11:15 +11:00
pelgraine
fad548b995 update firmware version and date 2026-02-15 02:01:03 +11:00
pelgraine
aa2e1af999 improvements to bookmark and cache optimisation for load times super fast and reduced serial output 2026-02-15 01:39:14 +11:00
pelgraine
472b0ee662 fixed background audio play and >> icon regression 2026-02-15 01:27:47 +11:00
pelgraine
1f5cbbd4db same repeater admin fixes as main 2026-02-15 01:02:35 +11:00
pelgraine
f451b49226 minor text alignment fix 2026-02-14 16:21:38 +11:00
pelgraine
d10aa2c571 fixed home view so that pin isn't shown when ble off. Add instructions for keyboard nav to home screen 2026-02-14 16:16:17 +11:00
pelgraine
a2e099f095 fixed home view so that pin isn't shown when ble off. Add instructions for keyboard nav to home screen 2026-02-14 16:06:39 +11:00
pelgraine
e5e41ff50b ui fixes for audiobook player, firmware version number updates, subdirectory support for both ereader and audiobook player file lists 2026-02-14 15:43:13 +11:00
pelgraine
2dc6977c20 updated audiobook player guide 2026-02-14 14:32:06 +11:00
pelgraine
5c540e9092 fixed settings page so corrected radio preset list options available and custom frequency edits refined. 2026-02-14 14:13:43 +11:00
pelgraine
670efa75b0 Fixed heap allocation order to sort out ble pairing for audiobook player version. Expanded char in uitask to allow firmware version suffix to display in splash screen. 2026-02-14 11:25:21 +11:00
pelgraine
3a486832c8 Merge branch 'main' into audio-player 2026-02-14 10:41:33 +11:00
pelgraine
0a892f2dad changed to hybrid render battery indicator method 2026-02-14 10:41:03 +11:00
pelgraine
7f75ea8309 Merge branch 'main' into audio-player 2026-02-14 10:17:58 +11:00
pelgraine
b1e3f2ac28 Back to original serialbleinterface to start afresh 2026-02-14 10:17:11 +11:00
pelgraine
ddfe05ad20 m4b incompatibility workaround by renaming file extension to m4a when playing 2026-02-14 01:49:25 +11:00
pelgraine
d51ca6db0b "Updated home screen 'background audio playing' icon to >> for clarity" 2026-02-14 01:05:01 +11:00
pelgraine
3ab8191d19 amened ble connection parameter (battery-saving) fix that was causing performance issues; updated firrmware date in mymesh; ui update for audiobook player; audiobook player now lkeeps playing on exit unless you pause it so background audio is enabled 2026-02-14 00:17:44 +11:00
pelgraine
546ce55c2b Gave up on trying to extract cover art from mp3 files and removed debug logs. Updated firmware version to match variant type 2026-02-13 23:27:32 +11:00
pelgraine
1f46bc1970 updated simple audio player ui to make control/nav more evident and i2s reset to try accommodate sample rate diffs between files added 2026-02-13 22:53:35 +11:00
pelgraine
db8a73004e audiobook function redo - initial success - attempt 1 2026-02-13 22:38:37 +11:00
pelgraine
209a2f1693 updated firmware version 2026-02-13 21:02:33 +11:00
pelgraine
4683711877 added firmware version build flag to stop device unbonding on new firmware version flash via vscode 2026-02-13 18:43:23 +11:00
pelgraine
9610277b83 ble battery life extension improvements and firmware bond ble pairing bug fix:
- Bond clearing on firmware version change (lines 38-67) — stores the firmware version string in SPIFFS at /ble_ver. On boot, if it doesn't match, all stored bonds are wiped. This fixes the forget/re-pair issue after flashing. Normal reboots keep pairing intact.
- TX power -3 dBm (line 73).
Connection parameter negotiation (lines 137-147) — latency=4 for power saving when connected.
- Advertising intervals 300ms/600ms (three places) — compromise between discovery speed and power.
- No controller power-down — the header file is unchanged from stock.
2026-02-13 18:40:51 +11:00
pelgraine
745efc4cc1 updated firmware version and date 2026-02-13 18:37:34 +11:00
pelgraine
7223395740 Improved device ui battery rendering for more accurate battery indicator 2026-02-13 18:36:17 +11:00
pelgraine
9ef1fa4f1b moved cpu.begin to earlier to reduce risk of brownout boot stuck at low voltage 2026-02-13 18:29:06 +11:00
pelgraine
2dd5c4f59f Slight nav bar ui change in notes mode and improved rename function responsiveness 2026-02-12 21:04:05 +11:00
pelgraine
ee2a27258b improved cursor tracking and rename file handling implented with r key in Notes file list view 2026-02-12 20:45:41 +11:00
pelgraine
5b868d51ca improved responsiveness and cursor tracking in notes function. updated firmware version in mymesh. 2026-02-12 20:32:21 +11:00
pelgraine
220006c229 "Increased buffer size. Shift+WASD → cursor nav in editing mode.
Shift+Backspace → save (editing), delete (reading/file list).
Shift+Enter → rename from file list.
RTC time passed via setTimestamp() when opening notes."
2026-02-12 20:18:57 +11:00
pelgraine
a60f4146d5 Create, edit, save and delete txt notes from the N menu from any home view screen 2026-02-12 19:41:59 +11:00
pelgraine
017b170e81 Moved epubprocessor files to better tree location and fixed txt file accented character rendering 2026-02-11 20:07:38 +11:00
pelgraine
9b0c13fd4c fixed redundant uppercase key handling and updated firmware date in mymesh 2026-02-11 11:42:59 +11:00
pelgraine
5e3a252748 fixed backupSettingsToSD bug 2026-02-11 11:36:24 +11:00
pelgraine
6c3fb569f4 Fixed accidental regressions caused by commit f0dc218 and minor bug fixes 2026-02-11 11:34:12 +11:00
pelgraine
fa747bfce2 increased gps duty cycle timing to 3 minutes awake 2026-02-10 22:40:05 +11:00
pelgraine
f0dc218a57 GPS duty cycle and cpu power management for extended battery life implemented 2026-02-10 22:26:59 +11:00
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
33 changed files with 8186 additions and 582 deletions

78
Audiobook Player Guide.md Normal file
View File

@@ -0,0 +1,78 @@
## Audiobook Player (Audio variant only)
Press **P** from the home screen to open the audiobook player.
Place `.mp3`, `.m4b`, `.m4a`, or `.wav` files in `/audiobooks/` on the SD card.
Files can be organised into subfolders (e.g. by author) — use **Enter** to
browse into folders and **.. (up)** to go back.
| Key | Action |
|-----|--------|
| W / S | Scroll file list / Volume up-down |
| Enter | Select book or folder / Play-Pause |
| A | Seek back 30 seconds |
| D | Seek forward 30 seconds |
| [ | Previous chapter (M4B only) |
| ] | Next chapter (M4B only) |
| Q | Leave player (audio continues) / Close book (when paused) / Exit (from file list) |
### Recommended Format
**MP3 is the recommended format.** M4B/M4A files are supported but currently
have playback issues with the ESP32-audioI2S library — some files may fail to
decode or produce silence. MP3 files play reliably and are the safest choice.
MP3 files should be encoded at a **44100 Hz sample rate**. Lower sample rates
(e.g. 22050 Hz) can cause distortion or playback failure due to ESP32-S3 I2S
hardware limitations.
**Bookmarks** are saved automatically every 30 seconds during playback and when
you stop or exit. Reopening a book resumes from your last position.
**Cover art** from M4B files is displayed as dithered monochrome on the e-ink
screen, along with title, author, and chapter information.
**Metadata caching** — the first time you open the audiobook player, it reads
title and author tags from each file (which can take a few seconds with many
files). This metadata is cached to the SD card so subsequent visits load
near-instantly. If you add or remove files the cache updates automatically.
### Background Playback
Audio continues playing when you leave the audiobook player screen. Press **Q**
while audio is playing to return to the home screen — a **>>** indicator will
appear in the status bar next to the battery icon to show that audio is active
in the background. Press **P** at any time to return to the player screen and
resume control.
If you pause or stop playback first and then press **Q**, the book is closed
and you're returned to the file list instead.
### Audio Hardware
The audiobook player uses the PCM5102A I2S DAC on the audio variant of the
T-Deck Pro (I2S pins: BCLK=7, DOUT=8, LRC=9). Audio is output via the 3.5mm
headphone jack.
> **Note:** The audiobook player is not available on the 4G modem variant
> due to I2S pin conflicts.
### SD Card Folder Structure
```
SD Card
├── audiobooks/
│ ├── .bookmarks/ (auto-created, stores resume positions)
│ │ ├── mybook.bmk
│ │ └── another.bmk
│ ├── .metacache (auto-created, speeds up file list loading)
│ ├── Ann Leckie/
│ │ ├── Ancillary Justice.mp3
│ │ └── Ancillary Sword.mp3
│ ├── Iain M. Banks/
│ │ └── The Algebraist.mp3
│ ├── mybook.mp3
│ └── podcast.mp3
├── books/ (existing — text reader)
│ └── ...
└── ...
```

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

@@ -523,6 +523,13 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
const char *text) {
markConnectionActive(from); // in case this is from a server, and we have a connection
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
// Forward CLI response to UI admin screen if admin session is active
#ifdef DISPLAY_CLASS
if (_admin_contact_idx >= 0 && _ui) {
_ui->onAdminCliResponse(from.name, text);
}
#endif
}
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
@@ -650,6 +657,53 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
return true;
}
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
uint32_t est_timeout;
int result = sendLogin(*recipient, password, est_timeout);
if (result == MSG_SEND_FAILED) {
MESH_DEBUG_PRINTLN("UI: Admin login send failed to %s", recipient->name);
return false;
}
clearPendingReqs();
memcpy(&pending_login, recipient->id.pub_key, 4);
_admin_contact_idx = contact_idx;
MESH_DEBUG_PRINTLN("UI: Admin login sent to %s (%s), timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
est_timeout);
return true;
}
bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) {
ContactInfo contact;
if (!getContactByIdx(contact_idx, contact)) return false;
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (!recipient) return false;
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
uint32_t est_timeout;
int result = sendCommandData(*recipient, timestamp, 0, command, est_timeout);
if (result == MSG_SEND_FAILED) {
MESH_DEBUG_PRINTLN("UI: CLI command send failed to %s: %s", recipient->name, command);
return false;
}
_admin_contact_idx = contact_idx;
MESH_DEBUG_PRINTLN("UI: CLI command sent to %s (%s): %s, timeout=%dms",
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
command, est_timeout);
return true;
}
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) {
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
@@ -708,6 +762,11 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
out_frame[i++] = 0; // legacy: is_admin = false
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
#ifdef DISPLAY_CLASS
// Notify UI of successful legacy login
if (_ui) _ui->onAdminLoginResult(true, 0, tag);
#endif
} else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
if (keep_alive_secs > 0) {
@@ -721,11 +780,21 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
i += 4; // NEW: include server timestamp
out_frame[i++] = data[7]; // NEW (v7): ACL permissions
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
#ifdef DISPLAY_CLASS
// Notify UI of successful login
if (_ui) _ui->onAdminLoginResult(true, data[6], tag);
#endif
} else {
out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
#ifdef DISPLAY_CLASS
// Notify UI of login failure
if (_ui) _ui->onAdminLoginResult(false, 0, 0);
#endif
}
_serial->writeFrame(out_frame, i);
} else if (len > 4 && // check for status response
@@ -897,6 +966,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
memset(send_scope.key, 0, sizeof(send_scope.key));
memset(_sent_track, 0, sizeof(_sent_track));
_sent_track_idx = 0;
_admin_contact_idx = -1;
// defaults
memset(&_prefs, 0, sizeof(_prefs));

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "10 Feb 2026"
#define FIRMWARE_BUILD_DATE "17 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.8.2"
#define FIRMWARE_VERSION "Meck v0.9.1A-NB"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -170,6 +170,12 @@ protected:
public:
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
void saveChannels() {
_store->saveChannels(this);
}
void saveContacts() {
_store->saveContacts(this);
}
private:
void writeOKFrame();
@@ -189,10 +195,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

@@ -29,4 +29,5 @@ struct NodePrefs { // persisted to file
uint32_t gps_interval; // GPS read interval in seconds
uint8_t autoadd_config; // bitmask for auto-add contacts config
int8_t utc_offset_hours; // UTC offset in hours (-12 to +14), default 0
uint8_t kb_flash_notify; // Keyboard backlight flash on new message (0=off, 1=on)
};

View File

@@ -3,13 +3,19 @@
#include "MyMesh.h"
#include "variant.h" // Board-specific defines (HAS_GPS, etc.)
#include "target.h" // For sensors, board, etc.
#include "GPSDutyCycle.h"
#include "CPUPowerManager.h"
// T-Deck Pro Keyboard support
#if defined(LilyGo_TDeck_Pro)
#include "TCA8418Keyboard.h"
#include <SD.h>
#include "TextReaderScreen.h"
#include "NotesScreen.h"
#include "ContactsScreen.h"
#include "ChannelScreen.h"
#include "SettingsScreen.h"
#include "RepeaterAdminScreen.h"
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
@@ -21,7 +27,7 @@
static uint8_t composeChannelIdx = 0;
static unsigned long lastComposeRefresh = 0;
static bool composeNeedsRefresh = false;
#define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms)
#define COMPOSE_REFRESH_INTERVAL 100 // ms before starting e-ink refresh after keypress (refresh itself takes ~644ms)
// DM compose mode (direct message to a specific contact)
static bool composeDM = false;
@@ -38,12 +44,127 @@
// Text reader mode state
static bool readerMode = false;
// Notes mode state
static bool notesMode = false;
// Audiobook player — Audio object is heap-allocated on first use to avoid
// consuming ~40KB of DMA/decode buffers at boot (starves BLE stack).
#include "AudiobookPlayerScreen.h"
#include "Audio.h"
Audio* audio = nullptr;
static bool audiobookMode = false;
// Power management
#if HAS_GPS
GPSDutyCycle gpsDuty;
#endif
CPUPowerManager cpuPower;
void initKeyboard();
void handleKeyboardInput();
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!
@@ -207,6 +328,9 @@ void setup() {
}
MESH_DEBUG_PRINTLN("setup() - radio_init() done");
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
cpuPower.begin();
MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()");
fast_rng.begin(radio_get_rng_seed());
MESH_DEBUG_PRINTLN("setup() - fast_rng.begin() done");
@@ -289,7 +413,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");
@@ -350,33 +498,77 @@ 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) {
cpuPower.setBoost(); // Boost CPU for EPUB processing
reader->bootIndex(*disp);
}
}
// Tell notes screen that SD is ready
NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen();
if (notesScr) {
notesScr->setSDReady(true);
}
// Audiobook player screen creation is deferred to first use (case 'p' in
// handleKeyboardInput) to avoid allocating Audio I2S/DMA buffers at boot,
// which would starve BLE of heap memory.
MESH_DEBUG_PRINTLN("setup() - Audiobook player deferred (lazy init on first use)");
// Do an initial settings backup to SD (captures any first-boot defaults)
backupSettingsToSD();
}
#endif
// Enable GPS by default on T-Deck Pro
// ---------------------------------------------------------------------------
// 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
// GPS duty cycle — honour saved pref, default to enabled on first boot
#if HAS_GPS
// Set GPS enabled in both sensor manager and node prefs
sensors.setSettingValue("gps", "1");
the_mesh.getNodePrefs()->gps_enabled = 1;
the_mesh.savePrefs();
MESH_DEBUG_PRINTLN("setup() - GPS enabled by default");
{
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
gpsDuty.setStreamCounter(&gpsStream);
gpsDuty.begin(gps_wanted);
if (gps_wanted) {
sensors.setSettingValue("gps", "1");
} else {
sensors.setSettingValue("gps", "0");
}
MESH_DEBUG_PRINTLN("setup() - GPS duty cycle started (enabled=%d)", gps_wanted);
}
#endif
// T-Deck Pro: BLE starts disabled for standalone-first operation
@@ -386,31 +578,75 @@ void setup() {
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
#endif
Serial.printf("setup() complete — free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
}
void loop() {
the_mesh.loop();
// GPS duty cycle — check for fix and manage power state
#if HAS_GPS
{
bool gps_hw_on = gpsDuty.loop();
if (gps_hw_on) {
LocationProvider* lp = sensors.getLocationProvider();
if (lp != NULL && lp->isValid()) {
gpsDuty.notifyFix();
}
}
}
#endif
sensors.loop();
// CPU frequency auto-timeout back to idle
cpuPower.loop();
// Audiobook: service audio decode regardless of which screen is active
{
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
if (abPlayer) {
abPlayer->audioTick();
// Keep CPU at high freq during active audio decode
if (abPlayer->isAudioActive()) {
cpuPower.setBoost();
}
}
}
#ifdef DISPLAY_CLASS
// Skip UITask rendering when in compose mode to prevent flickering
#if defined(LilyGo_TDeck_Pro)
if (!composeMode) {
// Also suppress during notes editing (same debounce pattern as compose)
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
bool notesSuppressLoop = notesEditing || notesRenaming;
if (!composeMode && !notesSuppressLoop) {
ui_task.loop();
} else {
// Handle debounced compose/emoji picker screen refresh
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
if (emojiPickerMode) {
drawEmojiPicker();
} else {
drawComposeScreen();
if (composeMode) {
if (emojiPickerMode) {
drawEmojiPicker();
} else {
drawComposeScreen();
}
} else if (notesSuppressLoop) {
// Notes editor/rename renders through UITask - force a refresh cycle
ui_task.forceRefresh();
ui_task.loop();
}
lastComposeRefresh = millis();
composeNeedsRefresh = false;
}
}
// Track reader mode state for key routing
// Track reader/notes/audiobook mode state for key routing
readerMode = ui_task.isOnTextReader();
notesMode = ui_task.isOnNotesScreen();
audiobookMode = ui_task.isOnAudiobookPlayer();
#else
ui_task.loop();
#endif
@@ -547,7 +783,7 @@ void handleKeyboardInput() {
}
// A/D keys switch channels (only when buffer is empty, not in DM mode)
if ((key == 'a' || key == 'A') && composePos == 0 && !composeDM) {
if ((key == 'a') && composePos == 0 && !composeDM) {
// Previous channel
if (composeChannelIdx > 0) {
composeChannelIdx--;
@@ -566,7 +802,7 @@ void handleKeyboardInput() {
return;
}
if ((key == 'd' || key == 'D') && composePos == 0 && !composeDM) {
if ((key == 'd') && composePos == 0 && !composeDM) {
// Next channel
ChannelDetails ch;
uint8_t nextIdx = composeChannelIdx + 1;
@@ -600,13 +836,47 @@ void handleKeyboardInput() {
return;
}
// *** AUDIOBOOK MODE ***
if (audiobookMode) {
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
// Q key: behavior depends on playback state
// - Playing: navigate home, audio continues in background
// - Paused/stopped: close book, return to file list
// - File list: exit player entirely
if (key == 'q') {
if (abPlayer->isBookOpen()) {
if (abPlayer->isAudioActive()) {
// Audio is playing — leave screen, audio continues via audioTick()
Serial.println("Leaving audiobook player (audio continues in background)");
ui_task.gotoHomeScreen();
} else {
// Paused or stopped — close book, show file list
abPlayer->closeCurrentBook();
Serial.println("Closed audiobook (was paused/stopped)");
// Stay on audiobook screen showing file list
}
} else {
abPlayer->exitPlayer();
Serial.println("Exiting audiobook player");
ui_task.gotoHomeScreen();
}
return;
}
// All other keys pass through to the player screen
ui_task.injectKey(key);
return;
}
// *** TEXT READER MODE ***
if (readerMode) {
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
// Q key: if reading, reader handles it (close book -> file list)
// if on file list, exit reader entirely
if (key == 'q' || key == 'Q') {
if (key == 'q') {
if (reader->isReading()) {
// Let the reader handle Q (close book, go to file list)
ui_task.injectKey('q');
@@ -619,105 +889,287 @@ 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();
return;
}
// All other keys pass through to the reader screen
ui_task.injectKey(key);
return;
}
// *** NOTES MODE ***
if (notesMode) {
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
// ---- EDITING MODE ----
if (notes->isEditing()) {
// Shift+Backspace = save and exit
if (key == '\b') {
if (keyboard.wasShiftConsumed()) {
Serial.println("Notes: Shift+Backspace, saving...");
notes->saveAndExit();
ui_task.forceRefresh();
return;
}
// Regular backspace - delete before cursor
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
// Cursor navigation via Shift+WASD (produces uppercase)
if (key == 'W') { notes->moveCursorUp(); composeNeedsRefresh = true; return; }
if (key == 'A') { notes->moveCursorLeft(); composeNeedsRefresh = true; return; }
if (key == 'S') { notes->moveCursorDown(); composeNeedsRefresh = true; return; }
if (key == 'D') { notes->moveCursorRight(); composeNeedsRefresh = true; return; }
// Q when buffer is empty or unchanged = exit (nothing to lose)
if (key == 'q' && (notes->isEmpty() || !notes->isDirty())) {
Serial.println("Notes: Q exit (nothing to save)");
notes->discardAndExit();
ui_task.forceRefresh();
return;
}
// Enter = newline (pass through with debounce)
if (key == '\r') {
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
// All other printable chars (lowercase only - uppercase consumed by cursor nav)
if (key >= 32 && key < 127) {
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
return;
}
// ---- RENAMING MODE ----
if (notes->isRenaming()) {
// All input goes to rename handler (debounced like editing)
ui_task.injectKey(key);
composeNeedsRefresh = true;
if (!notes->isRenaming()) {
// Exited rename mode (confirmed or cancelled)
ui_task.forceRefresh();
}
return;
}
// ---- DELETE CONFIRMATION MODE ----
if (notes->isConfirmingDelete()) {
ui_task.injectKey(key);
if (!notes->isConfirmingDelete()) {
// Exited confirm mode
ui_task.forceRefresh();
}
return;
}
// ---- FILE LIST MODE ----
if (notes->isInFileList()) {
if (key == 'q') {
notes->exitNotes();
Serial.println("Exiting notes");
ui_task.gotoHomeScreen();
return;
}
// Shift+Backspace on a file = delete with confirmation
if (key == '\b' && keyboard.wasShiftConsumed()) {
if (notes->startDeleteFromList()) {
ui_task.forceRefresh();
}
return;
}
// R on a file = rename
if (key == 'r') {
if (notes->startRename()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis() - COMPOSE_REFRESH_INTERVAL; // Trigger on next loop iteration
}
return;
}
// Normal keys pass through
ui_task.injectKey(key);
// Check if we just entered editing mode (new note via Enter)
if (notes->isEditing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
}
return;
}
// ---- READING MODE ----
if (notes->isReading()) {
if (key == 'q') {
ui_task.injectKey('q');
return;
}
// Shift+Backspace = delete note
if (key == '\b' && keyboard.wasShiftConsumed()) {
Serial.println("Notes: Deleting current note");
notes->deleteCurrentNote();
ui_task.forceRefresh();
return;
}
// All other keys (Enter for edit, W/S for page nav)
ui_task.injectKey(key);
if (notes->isEditing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
}
return;
}
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')) {
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 → settings screen via injectKey
ui_task.injectKey(key);
return;
}
// *** REPEATER ADMIN MODE ***
if (ui_task.isOnRepeaterAdmin()) {
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
RepeaterAdminScreen::AdminState astate = admin->getState();
bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed());
// In password entry: Shift+Del exits, all other keys pass through normally
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
if (shiftDel) {
Serial.println("Nav: Back to contacts from admin login");
ui_task.gotoContactsScreen();
} else {
ui_task.injectKey(key);
}
return;
}
// In menu state: Shift+Del exits to contacts, C opens compose
if (astate == RepeaterAdminScreen::STATE_MENU) {
if (shiftDel) {
Serial.println("Nav: Back to contacts from admin menu");
ui_task.gotoContactsScreen();
return;
}
// C key: allow entering compose mode from admin menu
if (key == 'c' || key == 'C') {
composeDM = false;
composeDMContactIdx = -1;
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
drawComposeScreen();
lastComposeRefresh = millis();
return;
}
// All other keys pass to admin screen
ui_task.injectKey(key);
return;
}
// In waiting/response/error states: convert Shift+Del to exit signal,
// pass all other keys through
if (shiftDel) {
ui_task.injectKey(KEY_ADMIN_EXIT);
} else {
ui_task.injectKey(key);
}
return;
}
// Normal mode - not composing
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();
}
break;
case 'm':
case 'M':
// Go to channel message screen
Serial.println("Opening channel messages");
ui_task.gotoChannelScreen();
break;
case 'r':
case 'R':
// Open text reader
Serial.println("Opening text reader");
ui_task.gotoTextReader();
break;
case 'n':
case 'N':
// Open contacts list
Serial.println("Opening contacts");
ui_task.gotoContactsScreen();
break;
case 'm':
// Go to channel message screen
Serial.println("Opening channel messages");
ui_task.gotoChannelScreen();
break;
case 'e':
// Open text reader (ebooks)
Serial.println("Opening text reader");
ui_task.gotoTextReader();
break;
case 'p':
// Open audiobook player — lazy-init Audio + screen on first use
Serial.println("Opening audiobook player");
if (!ui_task.getAudiobookScreen()) {
Serial.printf("Audiobook: lazy init — free heap: %d, largest block: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
audio = new Audio();
AudiobookPlayerScreen* abScreen = new AudiobookPlayerScreen(&ui_task, audio);
abScreen->setSDReady(sdCardReady);
ui_task.setAudiobookScreen(abScreen);
Serial.printf("Audiobook: init complete — free heap: %d\n", ESP.getFreeHeap());
}
ui_task.gotoAudiobookPlayer();
break;
case 'n':
// Open notes
Serial.println("Opening notes");
{
NotesScreen* notesScr2 = (NotesScreen*)ui_task.getNotesScreen();
if (notesScr2) {
uint32_t ts = rtc_clock.getCurrentTime();
int8_t utcOff = the_mesh.getNodePrefs()->utc_offset_hours;
notesScr2->setTimestamp(ts, utcOff);
}
}
ui_task.gotoNotesScreen();
break;
case 's':
// Open settings (from home), or navigate down on channel/contacts/admin
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
} else {
Serial.println("Opening settings");
ui_task.gotoSettingsScreen();
}
break;
case 'w':
case 'W':
// Navigate up/previous (scroll on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
} else {
Serial.println("Nav: Previous");
ui_task.injectKey(0xF2); // KEY_PREV
}
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)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
@@ -728,7 +1180,6 @@ void handleKeyboardInput() {
break;
case 'd':
case 'D':
// Navigate right or switch channel (on channel screen)
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
@@ -740,6 +1191,7 @@ void handleKeyboardInput() {
case '\r':
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
// or repeater admin for repeater contacts
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
int idx = cs->getSelectedContactIdx();
@@ -754,34 +1206,37 @@ void handleKeyboardInput() {
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
drawComposeScreen();
lastComposeRefresh = millis();
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
// Open repeater admin screen
char rname[32];
cs->getSelectedContactName(rname, sizeof(rname));
Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx);
ui_task.gotoRepeaterAdmin(idx);
} 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);
// Non-chat, non-repeater contact (room, sensor, etc.) - future use
Serial.printf("Selected 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 (admin mode handled above)
Serial.println("Nav: Back to home");
ui_task.gotoHomeScreen();
break;
case ' ':
@@ -789,6 +1244,11 @@ void handleKeyboardInput() {
Serial.println("Nav: Space (Next)");
ui_task.injectKey(0xF1); // KEY_NEXT
break;
case 'u':
// UTC offset edit (home screen GPS page handles this)
ui_task.injectKey('u');
break;
default:
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
@@ -937,6 +1397,8 @@ void drawEmojiPicker() {
void sendComposedMessage() {
if (composePos == 0) return;
cpuPower.setBoost(); // Boost CPU for crypto + radio TX
// Convert escape bytes back to UTF-8 for mesh transmission and BLE app
char utf8Buf[512];
@@ -982,4 +1444,23 @@ void sendComposedMessage() {
}
}
// ============================================================================
// ESP32-audioI2S CALLBACKS
// ============================================================================
// The audio library calls these global functions — must be defined at file scope.
void audio_info(const char *info) {
Serial.printf("Audio: %s\n", info);
}
void audio_eof_mp3(const char *info) {
Serial.printf("Audio: End of file - %s\n", info);
// Signal the player screen for auto-advance to next track
AudiobookPlayerScreen* abPlayer =
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
if (abPlayer) {
abPlayer->onEOF();
}
}
#endif // LilyGo_TDeck_Pro

File diff suppressed because it is too large Load Diff

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

@@ -30,7 +30,7 @@ private:
// Cached filtered contact indices for efficient scrolling
// We rebuild this on filter change or when entering the screen
static const int MAX_VISIBLE = 400; // matches MAX_CONTACTS build flag
static const int MAX_VISIBLE = 700; // must be >= MAX_CONTACTS build flag
uint16_t _filteredIdx[MAX_VISIBLE]; // indices into contact table
uint32_t _filteredTs[MAX_VISIBLE]; // cached last_advert_timestamp for sorting
int _filteredCount; // how many contacts match current filter
@@ -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 700 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

@@ -2,8 +2,8 @@
// =============================================================================
// EpubProcessor.h - Convert EPUB files to plain text for TextReaderScreen
//
// Pipeline: EPUB (ZIP) container.xml OPF spine extract chapters
// strip XHTML tags concatenated plain text cached .txt on SD
// Pipeline: EPUB (ZIP) → container.xml → OPF spine → extract chapters →
// strip XHTML tags → concatenated plain text → cached .txt on SD
//
// The resulting .txt file is placed in /books/ and picked up automatically
// by TextReaderScreen's existing pagination, indexing, and bookmarking.
@@ -14,6 +14,7 @@
#include <SD.h>
#include <FS.h>
#include "EpubZipReader.h"
#include "Utf8CP437.h"
// Maximum chapters in spine (most novels have 20-80)
#define EPUB_MAX_CHAPTERS 200
@@ -426,7 +427,7 @@ private:
//
// Handles:
// - Tag removal (everything between < and >)
// - <p>, <br>, <div>, <h1>-<h6> newlines
// - <p>, <br>, <div>, <h1>-<h6> → newlines
// - HTML entity decoding (&amp; &lt; &gt; &quot; &apos; &#NNN; &#xHH;)
// - Collapse multiple whitespace/newlines
// - Skip <head>, <style>, <script> content entirely
@@ -547,9 +548,9 @@ private:
}
}
// Handle UTF-8 multi-byte sequences (smart quotes, em dashes, etc.)
// These appear as raw bytes in XHTML and must be mapped to ASCII
// since the e-ink font only supports ASCII characters.
// Handle UTF-8 multi-byte sequences (smart quotes, em dashes, accented chars, etc.)
// These appear as raw bytes in XHTML. Typographic chars are mapped to ASCII;
// accented Latin chars are preserved as UTF-8 for CP437 rendering on e-ink.
if ((uint8_t)c >= 0xC0) {
uint32_t codepoint = 0;
int extraBytes = 0;
@@ -579,7 +580,8 @@ private:
if (valid && extraBytes > 0) {
p += extraBytes; // Skip continuation bytes (loop increments past lead byte)
// Map Unicode codepoints to ASCII equivalents
// Map Unicode codepoints to displayable equivalents
// Typographic chars → ASCII, accented chars → preserved as UTF-8
char mapped = 0;
switch (codepoint) {
case 0x2018: case 0x2019: mapped = '\''; break; // Smart single quotes
@@ -598,6 +600,21 @@ private:
default:
if (codepoint >= 0x20 && codepoint < 0x7F) {
mapped = (char)codepoint; // Basic ASCII range
} else if (unicodeToCP437(codepoint)) {
// Accented character that the e-ink font can render via CP437.
// Preserve as UTF-8 in the output; the text reader will decode
// and map to CP437 at render time.
if (codepoint <= 0x7FF) {
output[outPos++] = 0xC0 | (codepoint >> 6);
output[outPos++] = 0x80 | (codepoint & 0x3F);
} else if (codepoint <= 0xFFFF) {
output[outPos++] = 0xE0 | (codepoint >> 12);
output[outPos++] = 0x80 | ((codepoint >> 6) & 0x3F);
output[outPos++] = 0x80 | (codepoint & 0x3F);
}
lastWasNewline = false;
lastWasSpace = false;
continue; // Already wrote to output
} else {
continue; // Skip unmappable characters
}
@@ -608,7 +625,7 @@ private:
continue; // Skip malformed UTF-8
}
} else if ((uint8_t)c >= 0x80) {
// Stray continuation byte (0x80-0xBF) skip
// Stray continuation byte (0x80-0xBF) — skip
continue;
}
@@ -683,6 +700,37 @@ private:
if (entityLen == 5 && strncmp(entity, "ldquo", 5) == 0) return '"';
if (entityLen == 5 && strncmp(entity, "rdquo", 5) == 0) return '"';
// Common accented character entities → CP437 bytes for built-in font
if (entityLen == 6 && strncmp(entity, "eacute", 6) == 0) return (char)0x82; // é
if (entityLen == 6 && strncmp(entity, "egrave", 6) == 0) return (char)0x8A; // è
if (entityLen == 5 && strncmp(entity, "ecirc", 5) == 0) return (char)0x88; // ê
if (entityLen == 4 && strncmp(entity, "euml", 4) == 0) return (char)0x89; // ë
if (entityLen == 6 && strncmp(entity, "agrave", 6) == 0) return (char)0x85; // à
if (entityLen == 6 && strncmp(entity, "aacute", 6) == 0) return (char)0xA0; // á
if (entityLen == 5 && strncmp(entity, "acirc", 5) == 0) return (char)0x83; // â
if (entityLen == 4 && strncmp(entity, "auml", 4) == 0) return (char)0x84; // ä
if (entityLen == 6 && strncmp(entity, "ccedil", 6) == 0) return (char)0x87; // ç
if (entityLen == 6 && strncmp(entity, "iacute", 6) == 0) return (char)0xA1; // í
if (entityLen == 5 && strncmp(entity, "icirc", 5) == 0) return (char)0x8C; // î
if (entityLen == 4 && strncmp(entity, "iuml", 4) == 0) return (char)0x8B; // ï
if (entityLen == 6 && strncmp(entity, "igrave", 6) == 0) return (char)0x8D; // ì
if (entityLen == 6 && strncmp(entity, "oacute", 6) == 0) return (char)0xA2; // ó
if (entityLen == 5 && strncmp(entity, "ocirc", 5) == 0) return (char)0x93; // ô
if (entityLen == 4 && strncmp(entity, "ouml", 4) == 0) return (char)0x94; // ö
if (entityLen == 6 && strncmp(entity, "ograve", 6) == 0) return (char)0x95; // ò
if (entityLen == 6 && strncmp(entity, "uacute", 6) == 0) return (char)0xA3; // ú
if (entityLen == 5 && strncmp(entity, "ucirc", 5) == 0) return (char)0x96; // û
if (entityLen == 4 && strncmp(entity, "uuml", 4) == 0) return (char)0x81; // ü
if (entityLen == 6 && strncmp(entity, "ugrave", 6) == 0) return (char)0x97; // ù
if (entityLen == 6 && strncmp(entity, "ntilde", 6) == 0) return (char)0xA4; // ñ
if (entityLen == 6 && strncmp(entity, "Eacute", 6) == 0) return (char)0x90; // É
if (entityLen == 6 && strncmp(entity, "Ccedil", 6) == 0) return (char)0x80; // Ç
if (entityLen == 6 && strncmp(entity, "Ntilde", 6) == 0) return (char)0xA5; // Ñ
if (entityLen == 4 && strncmp(entity, "Auml", 4) == 0) return (char)0x8E; // Ä
if (entityLen == 4 && strncmp(entity, "Ouml", 4) == 0) return (char)0x99; // Ö
if (entityLen == 4 && strncmp(entity, "Uuml", 4) == 0) return (char)0x9A; // Ü
if (entityLen == 5 && strncmp(entity, "szlig", 5) == 0) return (char)0xE1; // ß
// Numeric entities: &#NNN; or &#xHH;
if (entityLen >= 2 && entity[0] == '#') {
int codepoint = 0;
@@ -701,14 +749,13 @@ private:
if (ch >= '0' && ch <= '9') codepoint = codepoint * 10 + (ch - '0');
}
}
// Map to ASCII (best effort - e-ink font is ASCII only)
// Map to displayable character (best effort)
if (codepoint >= 32 && codepoint < 127) return (char)codepoint;
if (codepoint == 160) return ' '; // non-breaking space
if (codepoint == 8211 || codepoint == 8212) return '-'; // en/em dash
if (codepoint == 8216 || codepoint == 8217) return '\''; // smart quotes
if (codepoint == 8220 || codepoint == 8221) return '"'; // smart quotes
if (codepoint == 8230) return '.'; // ellipsis
if (codepoint == 8226) return '*'; // bullet
// Try CP437 mapping for accented characters.
// The byte value will be passed through to the built-in font.
uint8_t cp437 = unicodeToCP437(codepoint);
if (cp437) return (char)cp437;
// Unknown codepoint > 127: skip it
return ' ';
}

View File

@@ -0,0 +1,651 @@
#pragma once
// =============================================================================
// M4BMetadata.h - Lightweight MP4/M4B atom parser for metadata extraction
//
// Walks the MP4 atom (box) tree to extract:
// - Title (moov/udta/meta/ilst/©nam)
// - Author (moov/udta/meta/ilst/©ART)
// - Cover art (moov/udta/meta/ilst/covr) - JPEG offset+size within file
// - Duration (moov/mvhd timescale + duration)
// - Chapter markers (moov/udta/chpl) - Nero-style chapter list
//
// Designed for embedded use: no dynamic allocation, reads directly from SD
// via Arduino File API, uses a small stack buffer for atom headers.
//
// Usage:
// M4BMetadata meta;
// File f = SD.open("/audiobooks/mybook.m4b");
// if (meta.parse(f)) {
// Serial.printf("Title: %s\n", meta.title);
// Serial.printf("Author: %s\n", meta.author);
// if (meta.hasCoverArt) {
// // JPEG data is at meta.coverOffset, meta.coverSize bytes
// }
// }
// f.close();
// =============================================================================
#include <SD.h>
// Maximum metadata string lengths (including null terminator)
#define M4B_MAX_TITLE 128
#define M4B_MAX_AUTHOR 64
#define M4B_MAX_CHAPTERS 100
struct M4BChapter {
uint32_t startMs; // Chapter start time in milliseconds
char name[48]; // Chapter title (truncated to fit)
};
class M4BMetadata {
public:
// Extracted metadata
char title[M4B_MAX_TITLE];
char author[M4B_MAX_AUTHOR];
bool hasCoverArt;
uint32_t coverOffset; // Byte offset of JPEG/PNG data within file
uint32_t coverSize; // Size of cover image data in bytes
uint8_t coverFormat; // 13=JPEG, 14=PNG (from MP4 well-known type)
uint32_t durationMs; // Total duration in milliseconds
uint32_t sampleRate; // Audio sample rate (from audio stsd)
uint32_t bitrate; // Approximate bitrate in bps
// Chapter data
M4BChapter chapters[M4B_MAX_CHAPTERS];
int chapterCount;
M4BMetadata() { clear(); }
void clear() {
title[0] = '\0';
author[0] = '\0';
hasCoverArt = false;
coverOffset = 0;
coverSize = 0;
coverFormat = 0;
durationMs = 0;
sampleRate = 44100;
bitrate = 0;
chapterCount = 0;
}
// Parse an open file. Returns true if at least title or duration was found.
// File position is NOT preserved — caller should seek as needed afterward.
bool parse(File& file) {
clear();
if (!file || file.size() < 8) return false;
_fileSize = file.size();
// Walk top-level atoms looking for 'moov'
uint32_t pos = 0;
while (pos < _fileSize) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_MOOV) {
parseMoov(file, hdr.dataOffset, hdr.dataOffset + hdr.dataSize);
break; // moov found and parsed, we're done
}
// Skip to next top-level atom
pos += hdr.size;
if (hdr.size == 0) break; // size=0 means "extends to EOF"
}
return (title[0] != '\0' || durationMs > 0);
}
// Get chapter index for a given playback position (milliseconds).
// Returns -1 if no chapters or position is before first chapter.
int getChapterForPosition(uint32_t positionMs) const {
if (chapterCount == 0) return -1;
int ch = 0;
for (int i = 1; i < chapterCount; i++) {
if (chapters[i].startMs > positionMs) break;
ch = i;
}
return ch;
}
// Get the start position of the next chapter after the given position.
// Returns 0 if no next chapter.
uint32_t getNextChapterMs(uint32_t positionMs) const {
for (int i = 0; i < chapterCount; i++) {
if (chapters[i].startMs > positionMs) return chapters[i].startMs;
}
return 0;
}
// Get the start position of the current or previous chapter.
uint32_t getPrevChapterMs(uint32_t positionMs) const {
uint32_t prev = 0;
for (int i = 0; i < chapterCount; i++) {
if (chapters[i].startMs >= positionMs) break;
prev = chapters[i].startMs;
}
return prev;
}
private:
uint32_t _fileSize;
// MP4 atom type codes (big-endian FourCC)
static constexpr uint32_t ATOM_MOOV = 0x6D6F6F76; // 'moov'
static constexpr uint32_t ATOM_MVHD = 0x6D766864; // 'mvhd'
static constexpr uint32_t ATOM_UDTA = 0x75647461; // 'udta'
static constexpr uint32_t ATOM_META = 0x6D657461; // 'meta'
static constexpr uint32_t ATOM_ILST = 0x696C7374; // 'ilst'
static constexpr uint32_t ATOM_NAM = 0xA96E616D; // '©nam'
static constexpr uint32_t ATOM_ART = 0xA9415254; // '©ART'
static constexpr uint32_t ATOM_COVR = 0x636F7672; // 'covr'
static constexpr uint32_t ATOM_DATA = 0x64617461; // 'data'
static constexpr uint32_t ATOM_CHPL = 0x6368706C; // 'chpl' (Nero chapters)
static constexpr uint32_t ATOM_TRAK = 0x7472616B; // 'trak'
static constexpr uint32_t ATOM_MDIA = 0x6D646961; // 'mdia'
static constexpr uint32_t ATOM_MDHD = 0x6D646864; // 'mdhd'
static constexpr uint32_t ATOM_HDLR = 0x68646C72; // 'hdlr'
struct AtomHeader {
uint32_t type;
uint64_t size; // Total atom size including header
uint32_t dataOffset; // File offset where data begins (after header)
uint64_t dataSize; // size - header_length
};
// Read a 32-bit big-endian value from file at current position
static uint32_t readU32BE(File& file) {
uint8_t buf[4];
file.read(buf, 4);
return ((uint32_t)buf[0] << 24) | ((uint32_t)buf[1] << 16) |
((uint32_t)buf[2] << 8) | buf[3];
}
// Read a 64-bit big-endian value
static uint64_t readU64BE(File& file) {
uint32_t hi = readU32BE(file);
uint32_t lo = readU32BE(file);
return ((uint64_t)hi << 32) | lo;
}
// Read a 16-bit big-endian value
static uint16_t readU16BE(File& file) {
uint8_t buf[2];
file.read(buf, 2);
return ((uint16_t)buf[0] << 8) | buf[1];
}
// Read atom header at given file offset
bool readAtomHeader(File& file, uint32_t offset, AtomHeader& hdr) {
if (offset + 8 > _fileSize) return false;
file.seek(offset);
uint32_t size32 = readU32BE(file);
hdr.type = readU32BE(file);
if (size32 == 1) {
// 64-bit extended size
if (offset + 16 > _fileSize) return false;
hdr.size = readU64BE(file);
hdr.dataOffset = offset + 16;
hdr.dataSize = (hdr.size > 16) ? hdr.size - 16 : 0;
} else if (size32 == 0) {
// Atom extends to end of file
hdr.size = _fileSize - offset;
hdr.dataOffset = offset + 8;
hdr.dataSize = hdr.size - 8;
} else {
hdr.size = size32;
hdr.dataOffset = offset + 8;
hdr.dataSize = (size32 > 8) ? size32 - 8 : 0;
}
return true;
}
// Parse the moov container atom
void parseMoov(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
switch (hdr.type) {
case ATOM_MVHD:
parseMvhd(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
break;
case ATOM_UDTA:
parseUdta(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
break;
case ATOM_TRAK:
break;
}
pos += (uint32_t)hdr.size;
}
}
// Parse mvhd (movie header) for duration
void parseMvhd(File& file, uint32_t offset, uint32_t size) {
file.seek(offset);
uint8_t version = file.read();
if (version == 0) {
file.seek(offset + 4); // skip version(1) + flags(3)
/* create_time */ readU32BE(file);
/* modify_time */ readU32BE(file);
uint32_t timescale = readU32BE(file);
uint32_t duration = readU32BE(file);
if (timescale > 0) {
durationMs = (uint32_t)((uint64_t)duration * 1000 / timescale);
}
} else if (version == 1) {
file.seek(offset + 4);
/* create_time */ readU64BE(file);
/* modify_time */ readU64BE(file);
uint32_t timescale = readU32BE(file);
uint64_t duration = readU64BE(file);
if (timescale > 0) {
durationMs = (uint32_t)(duration * 1000 / timescale);
}
}
}
// Parse udta container — contains meta and/or chpl
void parseUdta(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_META) {
parseMeta(file, hdr.dataOffset + 4,
hdr.dataOffset + (uint32_t)hdr.dataSize);
} else if (hdr.type == ATOM_CHPL) {
parseChpl(file, hdr.dataOffset, (uint32_t)hdr.dataSize);
}
pos += (uint32_t)hdr.size;
}
}
// Parse meta container — contains hdlr + ilst
void parseMeta(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_ILST) {
parseIlst(file, hdr.dataOffset, hdr.dataOffset + (uint32_t)hdr.dataSize);
}
pos += (uint32_t)hdr.size;
}
}
// Parse ilst (iTunes metadata list) — contains ©nam, ©ART, covr etc.
void parseIlst(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
switch (hdr.type) {
case ATOM_NAM:
extractTextData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize,
title, M4B_MAX_TITLE);
break;
case ATOM_ART:
extractTextData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize,
author, M4B_MAX_AUTHOR);
break;
case ATOM_COVR:
extractCoverData(file, hdr.dataOffset,
hdr.dataOffset + (uint32_t)hdr.dataSize);
break;
}
pos += (uint32_t)hdr.size;
}
}
// Extract text from a 'data' sub-atom within an ilst entry.
void extractTextData(File& file, uint32_t start, uint32_t end,
char* dest, int maxLen) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
uint32_t textOffset = hdr.dataOffset + 8;
uint32_t textLen = (uint32_t)hdr.dataSize - 8;
if (textLen > (uint32_t)(maxLen - 1)) textLen = maxLen - 1;
file.seek(textOffset);
file.read((uint8_t*)dest, textLen);
dest[textLen] = '\0';
return;
}
pos += (uint32_t)hdr.size;
}
}
// Extract cover art location from 'data' sub-atom within covr.
void extractCoverData(File& file, uint32_t start, uint32_t end) {
uint32_t pos = start;
while (pos < end) {
AtomHeader hdr;
if (!readAtomHeader(file, pos, hdr)) break;
if (hdr.size < 8) break;
if (hdr.type == ATOM_DATA && hdr.dataSize > 8) {
file.seek(hdr.dataOffset);
uint32_t typeIndicator = readU32BE(file);
uint8_t wellKnownType = typeIndicator & 0xFF;
coverOffset = hdr.dataOffset + 8;
coverSize = (uint32_t)hdr.dataSize - 8;
coverFormat = wellKnownType; // 13=JPEG, 14=PNG
hasCoverArt = (coverSize > 0);
Serial.printf("M4B: Cover art found - %s, %u bytes at offset %u\n",
wellKnownType == 13 ? "JPEG" :
wellKnownType == 14 ? "PNG" : "unknown",
coverSize, coverOffset);
return;
}
pos += (uint32_t)hdr.size;
}
}
// =====================================================================
// ID3v2 Parser for MP3 files
// =====================================================================
public:
// Parse ID3v2 tags from an MP3 file. Extracts title (TIT2), artist
// (TPE1), and cover art (APIC). Fills the same metadata fields as
// the M4B parser so decodeCoverArt() works unchanged.
bool parseID3v2(File& file) {
clear();
if (!file || file.size() < 10) return false;
file.seek(0);
uint8_t hdr[10];
if (file.read(hdr, 10) != 10) return false;
// Verify "ID3" magic
if (hdr[0] != 'I' || hdr[1] != 'D' || hdr[2] != '3') {
Serial.println("ID3: No ID3v2 header found");
return false;
}
uint8_t versionMajor = hdr[3]; // 3 = ID3v2.3, 4 = ID3v2.4
bool v24 = (versionMajor == 4);
bool hasExtHeader = (hdr[5] & 0x40) != 0;
// Tag size is syncsafe integer (4 x 7-bit bytes)
uint32_t tagSize = ((uint32_t)(hdr[6] & 0x7F) << 21) |
((uint32_t)(hdr[7] & 0x7F) << 14) |
((uint32_t)(hdr[8] & 0x7F) << 7) |
(hdr[9] & 0x7F);
uint32_t tagEnd = 10 + tagSize;
if (tagEnd > file.size()) tagEnd = file.size();
Serial.printf("ID3: v2.%d, %u bytes\n", versionMajor, tagSize);
// Skip extended header if present
uint32_t pos = 10;
if (hasExtHeader && pos + 4 < tagEnd) {
file.seek(pos);
uint32_t extSize;
if (v24) {
uint8_t eb[4];
file.read(eb, 4);
extSize = ((uint32_t)(eb[0] & 0x7F) << 21) |
((uint32_t)(eb[1] & 0x7F) << 14) |
((uint32_t)(eb[2] & 0x7F) << 7) |
(eb[3] & 0x7F);
} else {
extSize = readU32BE(file) + 4;
}
pos += extSize;
}
// Walk ID3v2 frames
bool foundTitle = false, foundArtist = false, foundCover = false;
while (pos + 10 < tagEnd) {
file.seek(pos);
uint8_t fhdr[10];
if (file.read(fhdr, 10) != 10) break;
if (fhdr[0] == 0) break;
char frameId[5] = { (char)fhdr[0], (char)fhdr[1],
(char)fhdr[2], (char)fhdr[3], '\0' };
uint32_t frameSize;
if (v24) {
frameSize = ((uint32_t)(fhdr[4] & 0x7F) << 21) |
((uint32_t)(fhdr[5] & 0x7F) << 14) |
((uint32_t)(fhdr[6] & 0x7F) << 7) |
(fhdr[7] & 0x7F);
} else {
frameSize = ((uint32_t)fhdr[4] << 24) | ((uint32_t)fhdr[5] << 16) |
((uint32_t)fhdr[6] << 8) | fhdr[7];
}
if (frameSize == 0 || pos + 10 + frameSize > tagEnd) break;
uint32_t dataStart = pos + 10;
// --- TIT2 (Title) ---
if (!foundTitle && strcmp(frameId, "TIT2") == 0 && frameSize > 1) {
id3ExtractText(file, dataStart, frameSize, title, M4B_MAX_TITLE);
foundTitle = (title[0] != '\0');
}
// --- TPE1 (Artist/Author) ---
if (!foundArtist && strcmp(frameId, "TPE1") == 0 && frameSize > 1) {
id3ExtractText(file, dataStart, frameSize, author, M4B_MAX_AUTHOR);
foundArtist = (author[0] != '\0');
}
// --- APIC (Attached Picture) ---
if (!foundCover && strcmp(frameId, "APIC") == 0 && frameSize > 20) {
id3ExtractAPIC(file, dataStart, frameSize);
foundCover = hasCoverArt;
}
pos = dataStart + frameSize;
// Early exit once we have everything
if (foundTitle && foundArtist && foundCover) break;
}
if (foundTitle) Serial.printf("ID3: Title: %s\n", title);
if (foundArtist) Serial.printf("ID3: Author: %s\n", author);
return (foundTitle || foundCover);
}
private:
// Extract text from a TIT2/TPE1 frame.
// Format: encoding(1) + text data
void id3ExtractText(File& file, uint32_t offset, uint32_t size,
char* dest, int maxLen) {
file.seek(offset);
uint8_t encoding = file.read();
uint32_t textLen = size - 1;
if (textLen == 0) return;
if (encoding == 0 || encoding == 3) {
// ISO-8859-1 or UTF-8 — read directly
uint32_t readLen = (textLen < (uint32_t)(maxLen - 1))
? textLen : (uint32_t)(maxLen - 1);
file.read((uint8_t*)dest, readLen);
dest[readLen] = '\0';
// Strip trailing nulls
while (readLen > 0 && dest[readLen - 1] == '\0') readLen--;
dest[readLen] = '\0';
}
else if (encoding == 1 || encoding == 2) {
// UTF-16 (with or without BOM) — crude ASCII extraction
// Static buffer to avoid stack overflow (loopTask has limited stack)
static uint8_t u16buf[128];
uint32_t readLen = (textLen > sizeof(u16buf)) ? sizeof(u16buf) : textLen;
file.read(u16buf, readLen);
uint32_t srcStart = 0;
// Skip BOM if present
if (readLen >= 2 && ((u16buf[0] == 0xFF && u16buf[1] == 0xFE) ||
(u16buf[0] == 0xFE && u16buf[1] == 0xFF))) {
srcStart = 2;
}
bool littleEndian = (srcStart >= 2 && u16buf[0] == 0xFF);
int dstIdx = 0;
for (uint32_t i = srcStart; i + 1 < readLen && dstIdx < maxLen - 1; i += 2) {
uint8_t lo = littleEndian ? u16buf[i] : u16buf[i + 1];
uint8_t hi = littleEndian ? u16buf[i + 1] : u16buf[i];
if (lo == 0 && hi == 0) break; // null terminator
if (hi == 0 && lo >= 0x20 && lo < 0x7F) {
dest[dstIdx++] = (char)lo;
} else {
dest[dstIdx++] = '?';
}
}
dest[dstIdx] = '\0';
}
}
// Extract APIC (cover art) frame.
// Format: encoding(1) + MIME(null-term) + picType(1) + desc(null-term) + imageData
void id3ExtractAPIC(File& file, uint32_t offset, uint32_t frameSize) {
file.seek(offset);
uint8_t encoding = file.read();
// Read MIME type (null-terminated ASCII)
char mime[32] = {0};
int mimeLen = 0;
while (mimeLen < 31) {
int b = file.read();
if (b < 0) return; // Read error
if (b == 0) break; // Null terminator = end of MIME string
mime[mimeLen++] = (char)b;
}
mime[mimeLen] = '\0';
// Picture type (1 byte)
uint8_t picType = file.read();
(void)picType;
// Skip description (null-terminated, encoding-dependent)
if (encoding == 0 || encoding == 3) {
// Single-byte null terminator
while (true) {
int b = file.read();
if (b < 0) return; // Read error
if (b == 0) break; // Null terminator
}
} else {
// UTF-16: double-null terminator
while (true) {
int b1 = file.read();
int b2 = file.read();
if (b1 < 0 || b2 < 0) return; // Read error
if (b1 == 0 && b2 == 0) break; // Double-null terminator
}
}
// Everything from here to end of frame is image data
uint32_t imgOffset = file.position();
uint32_t imgEnd = offset + frameSize;
if (imgOffset >= imgEnd) return;
uint32_t imgSize = imgEnd - imgOffset;
// Determine format from MIME type
bool isJpeg = (strstr(mime, "jpeg") || strstr(mime, "jpg"));
bool isPng = (strstr(mime, "png") != nullptr);
// Also detect by magic bytes if MIME is generic
if (!isJpeg && !isPng && imgSize > 4) {
file.seek(imgOffset);
uint8_t magic[4];
file.read(magic, 4);
if (magic[0] == 0xFF && magic[1] == 0xD8) isJpeg = true;
else if (magic[0] == 0x89 && magic[1] == 'P' &&
magic[2] == 'N' && magic[3] == 'G') isPng = true;
}
coverOffset = imgOffset;
coverSize = imgSize;
coverFormat = isJpeg ? 13 : (isPng ? 14 : 0);
hasCoverArt = (imgSize > 100 && (isJpeg || isPng));
if (hasCoverArt) {
Serial.printf("ID3: Cover %s, %u bytes\n",
isJpeg ? "JPEG" : "PNG", imgSize);
}
}
// Parse Nero-style chapter list (chpl atom).
void parseChpl(File& file, uint32_t offset, uint32_t size) {
if (size < 9) return;
file.seek(offset);
uint8_t version = file.read();
file.read(); // flags byte 1
file.read(); // flags byte 2
file.read(); // flags byte 3
file.read(); // reserved
uint32_t count;
if (version == 1) {
count = readU32BE(file);
} else {
count = file.read();
}
if (count > M4B_MAX_CHAPTERS) count = M4B_MAX_CHAPTERS;
chapterCount = 0;
for (uint32_t i = 0; i < count; i++) {
if (!file.available()) break;
uint64_t timestamp = readU64BE(file);
uint32_t startMs = (uint32_t)(timestamp / 10000); // 100ns -> ms
uint8_t nameLen = file.read();
if (nameLen == 0 || !file.available()) break;
M4BChapter& ch = chapters[chapterCount];
ch.startMs = startMs;
uint8_t readLen = (nameLen < sizeof(ch.name) - 1) ? nameLen : sizeof(ch.name) - 1;
file.read((uint8_t*)ch.name, readLen);
ch.name[readLen] = '\0';
if (nameLen > readLen) {
file.seek(file.position() + (nameLen - readLen));
}
chapterCount++;
}
Serial.printf("M4B: Found %d chapters\n", chapterCount);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ extern MyMesh the_mesh;
#define ADMIN_PASSWORD_MAX 32
#define ADMIN_RESPONSE_MAX 512 // CLI responses can be multi-line
#define ADMIN_TIMEOUT_MS 15000 // 15s timeout for login/commands
#define KEY_ADMIN_EXIT 0xFE // Special key: Shift+Backspace exit (injected by main.cpp)
class RepeaterAdminScreen : public UIScreen {
public:
@@ -280,31 +281,34 @@ public:
switch (_state) {
case STATE_PASSWORD_ENTRY:
display.print("Q:Back");
display.print("Sh+Del:Exit");
{
const char* right = "Enter:Login";
const char* right = "Ent:Login";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
break;
case STATE_LOGGING_IN:
case STATE_COMMAND_PENDING:
display.print("Q:Cancel");
display.print("Sh+Del:Cancel");
break;
case STATE_MENU:
display.print("Q:Back");
display.print("Sh+Del:Exit");
{
const char* mid = "W/S:Sel";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
const char* right = "Ent:Run";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
int leftEnd = display.getTextWidth("Sh+Del:Exit") + 2;
int rightStart = display.width() - display.getTextWidth(right) - 2;
int midX = leftEnd + (rightStart - leftEnd - display.getTextWidth(mid)) / 2;
display.setCursor(midX, footerY);
display.print(mid);
display.setCursor(rightStart, footerY);
display.print(right);
}
break;
case STATE_RESPONSE_VIEW:
case STATE_ERROR:
display.print("Q:Menu");
display.print("Sh+Del:Menu");
if (_responseLen > bodyHeight / 9) { // if scrollable
const char* right = "W/S:Scrll";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
@@ -327,8 +331,8 @@ public:
return handlePasswordInput(c);
case STATE_LOGGING_IN:
case STATE_COMMAND_PENDING:
// Q to cancel and go back
if (c == 'q' || c == 'Q') {
// Shift+Del to cancel and go back
if (c == KEY_ADMIN_EXIT) {
_state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU;
return true;
}
@@ -370,9 +374,9 @@ private:
}
bool handlePasswordInput(char c) {
// Q without any password typed = go back (return false to signal "not handled")
if ((c == 'q' || c == 'Q') && _pwdLen == 0) {
return false;
// Shift+Del = exit (always, regardless of password content)
if (c == KEY_ADMIN_EXIT) {
return false; // signal main.cpp to navigate back
}
// Enter to submit
@@ -472,8 +476,8 @@ private:
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
return executeMenuCommand((MenuItem)_menuSel);
}
// Q - back to contacts
if (c == 'q' || c == 'Q') {
// Shift+Del - back to contacts
if (c == KEY_ADMIN_EXIT) {
return false; // let UITask handle back navigation
}
// Number keys for quick selection
@@ -535,8 +539,8 @@ private:
_responseScroll++;
return true;
}
// Q - back to menu (or back to password on error)
if (c == 'q' || c == 'Q') {
// Shift+Del - back to menu (or back to password on error)
if (c == KEY_ADMIN_EXIT) {
if (_state == STATE_ERROR && _permissions == 0) {
// Not yet logged in, go back to password
_state = STATE_PASSWORD_ENTRY;

View File

@@ -0,0 +1,882 @@
#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[] = {
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
};
#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_MSG_NOTIFY, // Keyboard flash on new msg toggle
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_MSG_NOTIFY);
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_TEXT) {
snprintf(tmp, sizeof(tmp), "Freq: %s_ MHz", _editBuf);
} 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_MSG_NOTIFY:
snprintf(tmp, sizeof(tmp), "Msg Rcvd LED Light Pulse: %s",
_prefs->kb_flash_notify ? "ON" : "OFF");
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_FREQ) {
if (_editPos > 0) {
float f = strtof(_editBuf, nullptr);
f = constrain(f, 400.0f, 2500.0f);
_prefs->freq = f;
_radioChanged = true;
Serial.printf("Settings: Freq typed to %.3f\n", f);
}
_editMode = EDIT_NONE;
} 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_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_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_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: {
// Use text input so user can type exact frequencies like 916.575
char freqStr[16];
snprintf(freqStr, sizeof(freqStr), "%.3f", _prefs->freq);
startEditText(freqStr);
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_MSG_NOTIFY:
_prefs->kb_flash_notify = _prefs->kb_flash_notify ? 0 : 1;
the_mesh.savePrefs();
Serial.printf("Settings: Msg flash notify = %s\n",
_prefs->kb_flash_notify ? "ON" : "OFF");
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

@@ -4,6 +4,7 @@
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#include "EpubProcessor.h"
// Forward declarations
@@ -14,7 +15,7 @@ class UITask;
// ============================================================================
#define BOOKS_FOLDER "/books"
#define INDEX_FOLDER "/.indexes"
#define INDEX_VERSION 4
#define INDEX_VERSION 5 // v5: UTF-8 aware word wrap (accented char support)
#define PREINDEX_PAGES 100
#define READER_MAX_FILES 50
#define READER_BUF_SIZE 4096
@@ -57,6 +58,10 @@ inline WrapResult findLineBreak(const char* buffer, int bufLen, int lineStart, i
}
if (c >= 32) {
// Skip UTF-8 continuation bytes (0x80-0xBF) - the lead byte already
// counted as one display character, so don't double-count these.
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue;
charCount++;
if (c == ' ' || c == '\t') {
if (inWord) {
@@ -177,8 +182,10 @@ private:
// File list state
std::vector<String> _fileList;
std::vector<String> _dirList; // Subdirectories at current path
std::vector<FileCache> _fileCache;
int _selectedFile;
String _currentPath; // Current browsed directory
// Reading state
File _file;
@@ -386,8 +393,8 @@ private:
idxFile.read(&fullyFlag, 1);
idxFile.read((uint8_t*)&lastRead, 4);
// Verify file hasn't changed - try BOOKS_FOLDER first, then epub cache
String fullPath = String(BOOKS_FOLDER) + "/" + filename;
// Verify file hasn't changed - try current path first, then epub cache
String fullPath = _currentPath + "/" + filename;
File txtFile = SD.open(fullPath.c_str(), FILE_READ);
if (!txtFile) {
// Fallback: check epub cache directory
@@ -477,33 +484,94 @@ private:
// ---- File Scanning ----
// ---- Folder Navigation Helpers ----
bool isAtBooksRoot() const {
return _currentPath == String(BOOKS_FOLDER);
}
// Number of non-file entries at the start of the visual list
int dirEntryCount() const {
int count = _dirList.size();
if (!isAtBooksRoot()) count++; // ".." entry
return count;
}
// Total items in the visual list (parent + dirs + files)
int totalListItems() const {
return dirEntryCount() + (int)_fileList.size();
}
// What type of entry is at visual list index idx?
// Returns: 0 = ".." parent, 1 = directory, 2 = file
int itemTypeAt(int idx) const {
bool hasParent = !isAtBooksRoot();
if (hasParent && idx == 0) return 0; // ".."
int dirStart = hasParent ? 1 : 0;
if (idx < dirStart + (int)_dirList.size()) return 1; // directory
return 2; // file
}
// Get directory name for visual index (only valid when itemTypeAt == 1)
const String& dirNameAt(int idx) const {
int dirStart = isAtBooksRoot() ? 0 : 1;
return _dirList[idx - dirStart];
}
// Get file list index for visual index (only valid when itemTypeAt == 2)
int fileIndexAt(int idx) const {
return idx - dirEntryCount();
}
void navigateToParent() {
int lastSlash = _currentPath.lastIndexOf('/');
if (lastSlash > 0) {
_currentPath = _currentPath.substring(0, lastSlash);
} else {
_currentPath = BOOKS_FOLDER;
}
}
void navigateToChild(const String& dirName) {
_currentPath = _currentPath + "/" + dirName;
}
// ---- File Scanning ----
void scanFiles() {
_fileList.clear();
_dirList.clear();
if (!SD.exists(BOOKS_FOLDER)) {
SD.mkdir(BOOKS_FOLDER);
Serial.printf("TextReader: Created %s\n", BOOKS_FOLDER);
}
File root = SD.open(BOOKS_FOLDER);
File root = SD.open(_currentPath.c_str());
if (!root || !root.isDirectory()) return;
File f = root.openNextFile();
while (f && _fileList.size() < READER_MAX_FILES) {
if (!f.isDirectory()) {
String name = String(f.name());
int slash = name.lastIndexOf('/');
if (slash >= 0) name = name.substring(slash + 1);
while (f && (_fileList.size() + _dirList.size()) < READER_MAX_FILES) {
String name = String(f.name());
int slash = name.lastIndexOf('/');
if (slash >= 0) name = name.substring(slash + 1);
if (!name.startsWith(".") &&
(name.endsWith(".txt") || name.endsWith(".TXT") ||
name.endsWith(".epub") || name.endsWith(".EPUB"))) {
_fileList.push_back(name);
}
// Skip hidden files/dirs
if (name.startsWith(".")) {
f = root.openNextFile();
continue;
}
if (f.isDirectory()) {
_dirList.push_back(name);
} else if (name.endsWith(".txt") || name.endsWith(".TXT") ||
name.endsWith(".epub") || name.endsWith(".EPUB")) {
_fileList.push_back(name);
}
f = root.openNextFile();
}
root.close();
Serial.printf("TextReader: Found %d files\n", _fileList.size());
Serial.printf("TextReader: %s — %d dirs, %d files\n",
_currentPath.c_str(), (int)_dirList.size(), (int)_fileList.size());
}
// ---- Book Open/Close ----
@@ -513,7 +581,7 @@ private:
// ---- EPUB auto-conversion ----
String actualFilename = filename;
String actualFullPath = String(BOOKS_FOLDER) + "/" + filename;
String actualFullPath = _currentPath + "/" + filename;
bool isEpub = filename.endsWith(".epub") || filename.endsWith(".EPUB");
if (isEpub) {
@@ -603,7 +671,7 @@ private:
_currentPage = cache->lastReadPage;
}
// Already fully indexed open immediately
// Already fully indexed — open immediately
if (cache->fullyIndexed) {
_totalPages = _pagePositions.size();
_mode = READING;
@@ -613,7 +681,7 @@ private:
return;
}
// Partially indexed finish indexing with splash
// Partially indexed — finish indexing with splash
Serial.printf("TextReader: Finishing index for %s (have %d pages so far)\n",
actualFilename.c_str(), (int)_pagePositions.size());
@@ -629,7 +697,7 @@ private:
drawSplash("Indexing...", "Please wait", shortName);
if (_pagePositions.empty()) {
// Cache had no pages (e.g. dummy entry) full index from scratch
// Cache had no pages (e.g. dummy entry) — full index from scratch
_pagePositions.push_back(0);
indexPagesWordWrap(_file, 0, _pagePositions,
_linesPerPage, _charsPerLine, 0);
@@ -639,7 +707,7 @@ private:
_linesPerPage, _charsPerLine, 0);
}
} else {
// No cache full index from scratch
// No cache — full index from scratch
Serial.printf("TextReader: Full index for %s\n", actualFilename.c_str());
char shortName[28];
@@ -750,15 +818,26 @@ private:
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.print("Text Reader");
if (isAtBooksRoot()) {
display.print("Text Reader");
} else {
// Show current subfolder name
int lastSlash = _currentPath.lastIndexOf('/');
String folderName = (lastSlash >= 0) ? _currentPath.substring(lastSlash + 1) : _currentPath;
char hdrBuf[20];
strncpy(hdrBuf, folderName.c_str(), 17);
hdrBuf[17] = '\0';
display.print(hdrBuf);
}
sprintf(tmp, "[%d]", (int)_fileList.size());
int totalItems = totalListItems();
sprintf(tmp, "[%d]", totalItems);
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
display.print(tmp);
display.drawRect(0, 11, display.width(), 1);
if (_fileList.size() == 0) {
if (totalItems == 0) {
display.setCursor(0, 18);
display.setColor(DisplayDriver::LIGHT);
display.print("No files found");
@@ -775,8 +854,8 @@ private:
if (maxVisible > 15) maxVisible = 15;
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
(int)_fileList.size() - maxVisible));
int endIdx = min((int)_fileList.size(), startIdx + maxVisible);
totalItems - maxVisible));
int endIdx = min(totalItems, startIdx + maxVisible);
int y = startY;
for (int i = startIdx; i < endIdx; i++) {
@@ -795,27 +874,41 @@ private:
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
// Build display string: "> filename.txt *" (asterisk if has bookmark)
int type = itemTypeAt(i);
String line = selected ? "> " : " ";
String name = _fileList[i];
// Check for resume indicator
String suffix = "";
for (int j = 0; j < (int)_fileCache.size(); j++) {
if (_fileCache[j].filename == name && _fileCache[j].lastReadPage > 0) {
suffix = " *";
break;
if (type == 0) {
// ".." parent directory
line += ".. (up)";
} else if (type == 1) {
// Subdirectory
line += "/" + dirNameAt(i);
// Truncate if needed
if ((int)line.length() > _charsPerLine) {
line = line.substring(0, _charsPerLine - 3) + "...";
}
} else {
// File
int fi = fileIndexAt(i);
String name = _fileList[fi];
// Check for resume indicator
String suffix = "";
if (fi < (int)_fileCache.size()) {
if (_fileCache[fi].filename == name && _fileCache[fi].lastReadPage > 0) {
suffix = " *";
}
}
// Truncate if needed
int maxLen = _charsPerLine - 4 - suffix.length();
if ((int)name.length() > maxLen) {
name = name.substring(0, maxLen - 3) + "...";
}
line += name + suffix;
}
// Truncate if needed
int maxLen = _charsPerLine - 4 - suffix.length();
if ((int)name.length() > maxLen) {
name = name.substring(0, maxLen - 3) + "...";
}
line += name + suffix;
display.print(line.c_str());
y += listLineH;
}
display.setTextSize(1); // Restore
@@ -855,12 +948,40 @@ private:
if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;
display.setCursor(0, y);
// Print line character by character (only printable)
// Print line with UTF-8 decoding: multi-byte sequences are decoded
// to Unicode codepoints, then mapped to CP437 for the built-in font.
char charStr[2] = {0, 0};
for (int j = pos; j < wrap.lineEnd && j < _pageBufLen; j++) {
if (_pageBuf[j] >= 32) {
charStr[0] = _pageBuf[j];
int j = pos;
while (j < wrap.lineEnd && j < _pageBufLen) {
uint8_t b = (uint8_t)_pageBuf[j];
if (b < 32) {
// Control character — skip
j++;
continue;
}
if (b < 0x80) {
// Plain ASCII — print directly
charStr[0] = (char)b;
display.print(charStr);
j++;
} else if (b >= 0xC0) {
// UTF-8 lead byte — decode full sequence and map to CP437
int savedJ = j;
uint32_t cp = decodeUtf8Char(_pageBuf, wrap.lineEnd, &j);
uint8_t glyph = unicodeToCP437(cp);
if (glyph) {
charStr[0] = (char)glyph;
display.print(charStr);
}
// If unmappable (glyph==0), just skip the character
} else {
// Standalone byte 0x80-0xBF: not a valid UTF-8 lead byte.
// Treat as CP437 pass-through (e.g. from EPUB numeric entity decoding).
charStr[0] = (char)b;
display.print(charStr);
j++;
}
}
@@ -895,7 +1016,8 @@ public:
_bootIndexed(false), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5),
_headerHeight(14), _footerHeight(14),
_selectedFile(0), _fileOpen(false), _currentPage(0), _totalPages(0),
_selectedFile(0), _currentPath(BOOKS_FOLDER),
_fileOpen(false), _currentPage(0), _totalPages(0),
_pageBufLen(0), _contentDirty(true) {
}
@@ -997,7 +1119,7 @@ public:
// --- Pass 1: Fast cache load (no per-file splash screens) ---
// Try to load existing .idx files from SD for every file.
// This is just SD reads no indexing, no e-ink refreshes.
// This is just SD reads — no indexing, no e-ink refreshes.
_fileCache.clear();
_fileCache.resize(_fileList.size()); // Pre-allocate slots to maintain alignment with _fileList
@@ -1026,7 +1148,7 @@ public:
// Skip files that loaded from cache
if (_fileCache[i].filename.length() > 0) continue;
// Skip .epub files they'll be converted on first open via openBook()
// Skip .epub files — they'll be converted on first open via openBook()
if (_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB")) {
needsIndexCount--; // Don't count epubs in progress display
continue;
@@ -1035,8 +1157,8 @@ public:
indexProgress++;
drawBootSplash(indexProgress, needsIndexCount, _fileList[i]);
// Try BOOKS_FOLDER first, then epub cache fallback
String fullPath = String(BOOKS_FOLDER) + "/" + _fileList[i];
// Try current path first, then epub cache fallback
String fullPath = _currentPath + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
@@ -1133,6 +1255,8 @@ public:
}
bool handleFileListInput(char c) {
int total = totalListItems();
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_selectedFile > 0) {
@@ -1144,18 +1268,36 @@ public:
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_selectedFile < (int)_fileList.size() - 1) {
if (_selectedFile < total - 1) {
_selectedFile++;
return true;
}
return false;
}
// Enter - open selected file
// Enter - open selected item (directory or file)
if (c == '\r' || c == 13) {
if (_fileList.size() > 0 && _selectedFile < (int)_fileList.size()) {
openBook(_fileList[_selectedFile]);
if (total == 0 || _selectedFile >= total) return false;
int type = itemTypeAt(_selectedFile);
if (type == 0) {
// ".." — navigate to parent
navigateToParent();
rescanAndIndex();
return true;
} else if (type == 1) {
// Subdirectory — navigate into it
navigateToChild(dirNameAt(_selectedFile));
rescanAndIndex();
return true;
} else {
// File — open it
int fi = fileIndexAt(_selectedFile);
if (fi >= 0 && fi < (int)_fileList.size()) {
openBook(_fileList[fi]);
return true;
}
}
return false;
}
@@ -1163,6 +1305,53 @@ public:
return false;
}
// Rescan current directory and re-index its files.
// Called when navigating into or out of a subfolder.
void rescanAndIndex() {
scanFiles();
_selectedFile = 0;
// Rebuild file cache for the new directory's files
_fileCache.clear();
_fileCache.resize(_fileList.size());
for (int i = 0; i < (int)_fileList.size(); i++) {
if (!loadIndex(_fileList[i], _fileCache[i])) {
// Not cached — skip EPUB auto-indexing here (it happens on open)
// For .txt files, index now
if (!(_fileList[i].endsWith(".epub") || _fileList[i].endsWith(".EPUB"))) {
String fullPath = _currentPath + "/" + _fileList[i];
File file = SD.open(fullPath.c_str(), FILE_READ);
if (!file) {
// Try epub cache fallback
String cacheFallback = String("/books/.epub_cache/") + _fileList[i];
file = SD.open(cacheFallback.c_str(), FILE_READ);
}
if (file) {
FileCache& cache = _fileCache[i];
cache.filename = _fileList[i];
cache.fileSize = file.size();
cache.fullyIndexed = false;
cache.lastReadPage = 0;
cache.pagePositions.clear();
cache.pagePositions.push_back(0);
indexPagesWordWrap(file, 0, cache.pagePositions,
_linesPerPage, _charsPerLine,
PREINDEX_PAGES - 1);
cache.fullyIndexed = !file.available();
file.close();
saveIndex(cache.filename, cache.pagePositions, cache.fileSize,
cache.fullyIndexed, 0);
}
} else {
_fileCache[i].filename = "";
}
}
yield(); // Feed WDT between files
}
digitalWrite(SDCARD_CS, HIGH);
}
bool handleReadingInput(char c) {
// W/A - previous page
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {

View File

@@ -1,7 +1,10 @@
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "NotesScreen.h"
#include "RepeaterAdminScreen.h"
#include "target.h"
#include "GPSDutyCycle.h"
#ifdef WIFI_SSID
#include <WiFi.h>
#endif
@@ -33,11 +36,13 @@
#include "ChannelScreen.h"
#include "ContactsScreen.h"
#include "TextReaderScreen.h"
#include "SettingsScreen.h"
#include "AudiobookPlayerScreen.h"
class SplashScreen : public UIScreen {
UITask* _task;
unsigned long dismiss_after;
char _version_info[12];
char _version_info[24];
public:
SplashScreen(UITask* task) : _task(task) {
@@ -84,13 +89,18 @@ class HomeScreen : public UIScreen {
FIRST,
RECENT,
RADIO,
#ifdef BLE_PIN_CODE
BLUETOOTH,
#endif
ADVERT,
#if ENV_INCLUDE_GPS == 1
GPS,
#endif
#if UI_SENSORS_PAGE == 1
SENSORS,
#endif
#if HAS_BQ27220
BATTERY,
#endif
SHUTDOWN,
Count // keep as last
@@ -107,7 +117,7 @@ class HomeScreen : public UIScreen {
AdvertPath recent[UI_RECENT_LIST_SIZE];
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
// Use voltage-based estimation to match BLE app readings
uint8_t batteryPercentage = 0;
if (batteryMilliVolts > 0) {
@@ -136,6 +146,8 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
int iconX = display.width() - totalWidth;
int iconY = 0; // vertically align with node name text
if (outIconX) *outIconX = iconX;
// battery outline
display.drawRect(iconX, iconY, iconWidth, iconHeight);
@@ -155,6 +167,22 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
display.setTextSize(1); // restore default text size
}
// ---- Audio background playback indicator ----
// Shows a small play symbol to the left of the battery icon when an
// audiobook is actively playing in the background.
// Uses the font renderer (not manual pixel drawing) since it handles
// the e-ink coordinate scaling correctly.
void renderAudioIndicator(DisplayDriver& display, int batteryLeftX) {
if (!_task->isAudioPlayingInBackground()) return;
display.setColor(DisplayDriver::GREEN);
display.setTextSize(0); // tiny font (same as clock & battery %)
int x = batteryLeftX - display.getTextWidth(">>") - 2;
display.setCursor(x, -3); // align vertically with battery text
display.print(">>");
display.setTextSize(1); // restore
}
CayenneLPP sensors_lpp;
int sensors_nb = 0;
bool sensors_scroll = false;
@@ -203,16 +231,20 @@ public:
int render(DisplayDriver& display) override {
char tmp[80];
// node name
display.setTextSize(1);
// node name (tinyfont to avoid overlapping clock)
display.setTextSize(0);
display.setColor(DisplayDriver::GREEN);
char filtered_name[sizeof(_node_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
display.setCursor(0, 0);
display.setCursor(0, -3);
display.print(filtered_name);
// battery voltage
renderBatteryIndicator(display, _task->getBattMilliVolts());
int battLeftX = display.width(); // default if battery doesn't render
renderBatteryIndicator(display, _task->getBattMilliVolts(), &battLeftX);
// audio background playback indicator (>> icon next to battery)
renderAudioIndicator(display, battLeftX);
// centered clock (tinyfont) - only show when time is valid
{
@@ -249,28 +281,54 @@ public:
}
if (_page == HomePage::FIRST) {
int y = 20;
display.setColor(DisplayDriver::YELLOW);
display.setTextSize(2);
sprintf(tmp, "MSG: %d", _task->getMsgCount());
display.drawTextCentered(display.width() / 2, 20, tmp);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#ifdef WIFI_SSID
IPAddress ip = WiFi.localIP();
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 54, tmp);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 12;
#endif
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID)
if (_task->hasConnection()) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
} else if (the_mesh.getBLEPin() != 0) { // BT pin
display.drawTextCentered(display.width() / 2, y, "< Connected >");
y += 12;
#ifdef BLE_PIN_CODE
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
display.setColor(DisplayDriver::RED);
display.setTextSize(2);
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
display.drawTextCentered(display.width() / 2, 43, tmp);
display.drawTextCentered(display.width() / 2, y, tmp);
y += 18;
#endif
}
#endif
// Menu shortcuts - tinyfont monospaced grid
y += 6;
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0); // tinyfont 6x8 monospaced
display.drawTextCentered(display.width() / 2, y, "Press:");
y += 12;
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
y += 10;
display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks");
y += 14;
// Nav hint
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
display.setTextSize(1); // restore
} else if (_page == HomePage::RECENT) {
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
display.setColor(DisplayDriver::GREEN);
@@ -315,6 +373,7 @@ public:
display.setCursor(0, 53);
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
display.print(tmp);
#ifdef BLE_PIN_CODE
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18,
@@ -322,27 +381,44 @@ public:
32, 32);
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
#endif
} else if (_page == HomePage::ADVERT) {
display.setColor(DisplayDriver::GREEN);
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL);
#if ENV_INCLUDE_GPS == 1
} else if (_page == HomePage::GPS) {
extern GPSDutyCycle gpsDuty;
extern GPSStreamCounter gpsStream;
LocationProvider* nmea = sensors.getLocationProvider();
char buf[50];
int y = 18;
bool gps_state = _task->getGPSState();
#ifdef PIN_GPS_SWITCH
bool hw_gps_state = digitalRead(PIN_GPS_SWITCH);
if (gps_state != hw_gps_state) {
strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)");
// GPS state line with duty cycle info
if (!_node_prefs->gps_enabled) {
strcpy(buf, "gps off");
} else {
strcpy(buf, gps_state ? "gps on" : "gps off");
switch (gpsDuty.getState()) {
case GPSDutyState::ACQUIRING: {
uint32_t elapsed = gpsDuty.acquireElapsedSecs();
sprintf(buf, "acquiring %us", (unsigned)elapsed);
break;
}
case GPSDutyState::SLEEPING: {
uint32_t remain = gpsDuty.sleepRemainingSecs();
if (remain >= 60) {
sprintf(buf, "sleep %um%02us", (unsigned)(remain / 60), (unsigned)(remain % 60));
} else {
sprintf(buf, "sleep %us", (unsigned)remain);
}
break;
}
default:
strcpy(buf, "gps off");
}
}
#else
strcpy(buf, gps_state ? "gps on" : "gps off");
#endif
display.drawTextLeftAlign(0, y, buf);
if (nmea == NULL) {
y = y + 12;
display.drawTextLeftAlign(0, y, "Can't access GPS");
@@ -354,6 +430,19 @@ public:
sprintf(buf, "%d", nmea->satellitesCount());
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
// NMEA sentence counter — confirms baud rate and data flow
display.drawTextLeftAlign(0, y, "sentences");
if (gpsDuty.isHardwareOn()) {
uint16_t sps = gpsStream.getSentencesPerSec();
uint32_t total = gpsStream.getSentenceCount();
sprintf(buf, "%u/s (%lu)", sps, (unsigned long)total);
} else {
strcpy(buf, "hw off");
}
display.drawTextRightAlign(display.width()-1, y, buf);
y = y + 12;
display.drawTextLeftAlign(0, y, "pos");
sprintf(buf, "%.4f %.4f",
nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.);
@@ -472,6 +561,58 @@ public:
}
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
else sensors_scroll_offset = 0;
#endif
#if HAS_BQ27220
} else if (_page == HomePage::BATTERY) {
char buf[30];
int y = 18;
// Title
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(display.width() / 2, y, "Battery Gauge");
y += 12;
display.setColor(DisplayDriver::LIGHT);
// Time to empty
uint16_t tte = board.getTimeToEmpty();
display.drawTextLeftAlign(0, y, "remaining");
if (tte == 0xFFFF || tte == 0) {
strcpy(buf, tte == 0 ? "depleted" : "charging");
} else if (tte >= 60) {
sprintf(buf, "%dh %dm", tte / 60, tte % 60);
} else {
sprintf(buf, "%d min", tte);
}
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Average current
int16_t avgCur = board.getAvgCurrent();
display.drawTextLeftAlign(0, y, "avg current");
sprintf(buf, "%d mA", avgCur);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Average power
int16_t avgPow = board.getAvgPower();
display.drawTextLeftAlign(0, y, "avg power");
sprintf(buf, "%d mW", avgPow);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Voltage (already available)
uint16_t mv = board.getBattMilliVolts();
display.drawTextLeftAlign(0, y, "voltage");
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
display.drawTextRightAlign(display.width()-1, y, buf);
y += 10;
// Remaining capacity
uint16_t remCap = board.getRemainingCapacity();
display.drawTextLeftAlign(0, y, "remaining cap");
sprintf(buf, "%d mAh", remCap);
display.drawTextRightAlign(display.width()-1, y, buf);
#endif
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(DisplayDriver::GREEN);
@@ -532,6 +673,7 @@ public:
}
return true;
}
#ifdef BLE_PIN_CODE
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
_task->disableSerial();
@@ -540,6 +682,7 @@ public:
}
return true;
}
#endif
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
@@ -707,6 +850,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
vibration.begin();
#endif
// Keyboard backlight for message flash notifications
#ifdef KB_BL_PIN
pinMode(KB_BL_PIN, OUTPUT);
digitalWrite(KB_BL_PIN, LOW);
#endif
ui_started_at = millis();
_alert_expiry = 0;
@@ -716,6 +865,10 @@ 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);
notes_screen = new NotesScreen(this);
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
setCurrScreen(splash);
}
@@ -757,7 +910,7 @@ switch(t){
void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
if (msgcount == 0 && curr == msg_preview) {
gotoHomeScreen();
}
}
@@ -786,9 +939,12 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
#if defined(LilyGo_TDeck_Pro)
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
// Messages are stored in channel history, accessible via 'M' key
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
// Suppress alert entirely on admin screen - it needs focused interaction
if (!isOnRepeaterAdmin()) {
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
}
#else
// Other devices: Show full preview screen (legacy behavior)
setCurrScreen(msg_preview);
@@ -803,6 +959,14 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
_next_refresh = 100; // trigger refresh
}
}
// Keyboard flash notification
#ifdef KB_BL_PIN
if (_node_prefs->kb_flash_notify) {
digitalWrite(KB_BL_PIN, HIGH);
_kb_flash_off_at = millis() + 200; // 200ms flash
}
#endif
}
void UITask::userLedHandler() {
@@ -938,6 +1102,14 @@ void UITask::loop() {
userLedHandler();
// Turn off keyboard flash after timeout
#ifdef KB_BL_PIN
if (_kb_flash_off_at && millis() >= _kb_flash_off_at) {
digitalWrite(KB_BL_PIN, LOW);
_kb_flash_off_at = 0;
}
#endif
#ifdef PIN_BUZZER
if (buzzer.isPlaying()) buzzer.loop();
#endif
@@ -1035,39 +1207,36 @@ char UITask::handleTripleClick(char c) {
}
bool UITask::getGPSState() {
if (_sensors != NULL) {
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
return !strcmp(_sensors->getSettingValue(i), "1");
}
}
}
return false;
#if ENV_INCLUDE_GPS == 1
return _node_prefs != NULL && _node_prefs->gps_enabled;
#else
return false;
#endif
}
void UITask::toggleGPS() {
#if ENV_INCLUDE_GPS == 1
extern GPSDutyCycle gpsDuty;
if (_sensors != NULL) {
// toggle GPS on/off
int num = _sensors->getNumSettings();
for (int i = 0; i < num; i++) {
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
notify(UIEventType::ack);
} else {
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
break;
if (_node_prefs->gps_enabled) {
// Disable GPS — cut hardware power
_sensors->setSettingValue("gps", "0");
_node_prefs->gps_enabled = 0;
gpsDuty.disable();
notify(UIEventType::ack);
} else {
// Enable GPS — start duty cycle
_sensors->setSettingValue("gps", "1");
_node_prefs->gps_enabled = 1;
gpsDuty.enable();
notify(UIEventType::ack);
}
the_mesh.savePrefs();
showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800);
_next_refresh = 0;
}
}
#endif
}
void UITask::toggleBuzzer() {
@@ -1155,6 +1324,53 @@ void UITask::gotoTextReader() {
_next_refresh = 100;
}
void UITask::gotoNotesScreen() {
NotesScreen* notes = (NotesScreen*)notes_screen;
if (_display != NULL) {
notes->enter(*_display);
}
setCurrScreen(notes_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_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;
}
void UITask::gotoAudiobookPlayer() {
if (audiobook_screen == nullptr) return; // No audio hardware
AudiobookPlayerScreen* abPlayer = (AudiobookPlayerScreen*)audiobook_screen;
if (_display != NULL) {
abPlayer->enter(*_display);
}
setCurrScreen(audiobook_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();
}
@@ -1166,4 +1382,55 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
// Add to channel history with path_len=0 (local message)
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
}
void UITask::gotoRepeaterAdmin(int contactIdx) {
// Lazy-initialize on first use (same pattern as audiobook player)
if (repeater_admin == nullptr) {
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
}
// Get contact name for the screen header
ContactInfo contact;
char name[32] = "Unknown";
if (the_mesh.getContactByIdx(contactIdx, contact)) {
strncpy(name, contact.name, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';
}
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
admin->openForContact(contactIdx, name);
setCurrScreen(repeater_admin);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
_next_refresh = 100; // trigger re-render
}
}
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
if (repeater_admin && isOnRepeaterAdmin()) {
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
_next_refresh = 100; // trigger re-render
}
}
bool UITask::isAudioPlayingInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isAudioActive();
}
bool UITask::isAudioPausedInBackground() const {
if (!audiobook_screen) return false;
AudiobookPlayerScreen* player = (AudiobookPlayerScreen*)audiobook_screen;
return player->isBookOpen() && !player->isAudioActive();
}

View File

@@ -32,6 +32,7 @@ class UITask : public AbstractUITask {
GenericVibration vibration;
#endif
unsigned long _next_refresh, _auto_off;
unsigned long _kb_flash_off_at; // Keyboard flash turn-off timer
NodePrefs* _node_prefs;
char _alert[80];
unsigned long _alert_expiry;
@@ -54,6 +55,10 @@ 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* notes_screen; // Notes editor screen
UIScreen* settings_screen; // Settings/onboarding screen
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
UIScreen* repeater_admin; // Repeater admin screen
UIScreen* curr;
void userLedHandler();
@@ -70,6 +75,7 @@ public:
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) {
next_batt_chck = _next_refresh = 0;
_kb_flash_off_at = 0;
ui_started_at = 0;
curr = NULL;
}
@@ -79,6 +85,11 @@ public:
void gotoChannelScreen(); // Navigate to channel message screen
void gotoContactsScreen(); // Navigate to contacts list
void gotoTextReader(); // *** NEW: Navigate to text reader ***
void gotoNotesScreen(); // Navigate to notes editor
void gotoSettingsScreen(); // Navigate to settings
void gotoOnboarding(); // Navigate to settings in onboarding mode
void gotoAudiobookPlayer(); // Navigate to audiobook player
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
void showAlert(const char* text, int duration_millis) override;
void forceRefresh() override { _next_refresh = 100; }
int getMsgCount() const { return _msgcount; }
@@ -87,24 +98,44 @@ 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 isOnNotesScreen() const { return curr == notes_screen; }
bool isOnSettingsScreen() const { return curr == settings_screen; }
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
// Check if audio is playing/paused in the background (for status indicators)
bool isAudioPlayingInBackground() const;
bool isAudioPausedInBackground() const;
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);
// Add a sent message to the channel screen history
void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override;
// Repeater admin callbacks
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
void onAdminCliResponse(const char* from_name, const char* text) override;
// Get current screen for checking state
UIScreen* getCurrentScreen() const { return curr; }
UIScreen* getMsgPreviewScreen() const { return msg_preview; }
UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW ***
UIScreen* getNotesScreen() const { return notes_screen; }
UIScreen* getContactsScreen() const { return contacts_screen; }
UIScreen* getChannelScreen() const { return channel_screen; }
UIScreen* getSettingsScreen() const { return settings_screen; }
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
// from AbstractUITask
void msgRead(int msgcount) override;

View File

@@ -0,0 +1,152 @@
#pragma once
// =============================================================================
// Utf8CP437.h - UTF-8 decoding and Unicode-to-CP437 mapping
//
// The Adafruit GFX built-in 6x8 font uses the CP437 character set for codes
// 128-255. This header provides utilities to:
// 1. Decode UTF-8 multi-byte sequences into Unicode codepoints
// 2. Map Unicode codepoints to CP437 byte values for display
//
// Used by both EpubProcessor (at XHTML→text conversion time) and
// TextReaderScreen (at render time for plain .txt files).
// =============================================================================
// Map a Unicode codepoint to its CP437 equivalent byte.
// Returns the CP437 byte (0x80-0xFF) for supported accented characters,
// the codepoint itself for ASCII (0x20-0x7E), or 0 if unmappable.
inline uint8_t unicodeToCP437(uint32_t cp) {
// ASCII passthrough
if (cp >= 0x20 && cp < 0x7F) return (uint8_t)cp;
switch (cp) {
// Uppercase accented
case 0x00C7: return 0x80; // Ç
case 0x00C9: return 0x90; // É
case 0x00C4: return 0x8E; // Ä
case 0x00C5: return 0x8F; // Å
case 0x00C6: return 0x92; // Æ
case 0x00D6: return 0x99; // Ö
case 0x00DC: return 0x9A; // Ü
case 0x00D1: return 0xA5; // Ñ
// Lowercase accented
case 0x00E9: return 0x82; // é
case 0x00E2: return 0x83; // â
case 0x00E4: return 0x84; // ä
case 0x00E0: return 0x85; // à
case 0x00E5: return 0x86; // å
case 0x00E7: return 0x87; // ç
case 0x00EA: return 0x88; // ê
case 0x00EB: return 0x89; // ë
case 0x00E8: return 0x8A; // è
case 0x00EF: return 0x8B; // ï
case 0x00EE: return 0x8C; // î
case 0x00EC: return 0x8D; // ì
case 0x00E6: return 0x91; // æ
case 0x00F4: return 0x93; // ô
case 0x00F6: return 0x94; // ö
case 0x00F2: return 0x95; // ò
case 0x00FB: return 0x96; // û
case 0x00F9: return 0x97; // ù
case 0x00FF: return 0x98; // ÿ
case 0x00FC: return 0x81; // ü
case 0x00E1: return 0xA0; // á
case 0x00ED: return 0xA1; // í
case 0x00F3: return 0xA2; // ó
case 0x00FA: return 0xA3; // ú
case 0x00F1: return 0xA4; // ñ
// Currency / symbols
case 0x00A2: return 0x9B; // ¢
case 0x00A3: return 0x9C; // £
case 0x00A5: return 0x9D; // ¥
case 0x00BF: return 0xA8; // ¿
case 0x00A1: return 0xAD; // ¡
case 0x00AB: return 0xAE; // «
case 0x00BB: return 0xAF; // »
case 0x00B0: return 0xF8; // °
case 0x00B1: return 0xF1; // ±
case 0x00B5: return 0xE6; // µ
case 0x00DF: return 0xE1; // ß
// Typographic (smart quotes, dashes, etc.)
case 0x2018: case 0x2019: return '\''; // Smart single quotes
case 0x201C: case 0x201D: return '"'; // Smart double quotes
case 0x2013: case 0x2014: return '-'; // En/em dash
case 0x2010: case 0x2011: case 0x2012: case 0x2015: return '-'; // Hyphens/bars
case 0x2026: return 0xFD; // Ellipsis (CP437 has no …, use ²? no, skip)
case 0x2022: return 0x07; // Bullet → CP437 bullet
case 0x00A0: return ' '; // Non-breaking space
case 0x2039: case 0x203A: return '\''; // Single guillemets
case 0x2032: return '\''; // Prime
case 0x2033: return '"'; // Double prime
default: return 0; // Unmappable
}
}
// Decode a single UTF-8 character from a byte buffer.
// Returns the Unicode codepoint and advances *pos past the full sequence.
// If the sequence is invalid, returns 0xFFFD (replacement char) and advances by 1.
//
// buf: input buffer
// bufLen: total buffer length
// pos: pointer to current position (updated on return)
inline uint32_t decodeUtf8Char(const char* buf, int bufLen, int* pos) {
int i = *pos;
if (i >= bufLen) return 0;
uint8_t c = (uint8_t)buf[i];
// ASCII (single byte)
if (c < 0x80) {
*pos = i + 1;
return c;
}
// Continuation byte without lead byte — skip
if (c < 0xC0) {
*pos = i + 1;
return 0xFFFD;
}
uint32_t codepoint;
int extraBytes;
if ((c & 0xE0) == 0xC0) {
codepoint = c & 0x1F;
extraBytes = 1;
} else if ((c & 0xF0) == 0xE0) {
codepoint = c & 0x0F;
extraBytes = 2;
} else if ((c & 0xF8) == 0xF0) {
codepoint = c & 0x07;
extraBytes = 3;
} else {
*pos = i + 1;
return 0xFFFD;
}
// Verify we have enough bytes and they're valid continuation bytes
if (i + extraBytes >= bufLen) {
*pos = i + 1;
return 0xFFFD;
}
for (int b = 1; b <= extraBytes; b++) {
uint8_t cb = (uint8_t)buf[i + b];
if ((cb & 0xC0) != 0x80) {
*pos = i + 1;
return 0xFFFD;
}
codepoint = (codepoint << 6) | (cb & 0x3F);
}
*pos = i + 1 + extraBytes;
return codepoint;
}
// Check if a byte is a UTF-8 continuation byte (10xxxxxx)
inline bool isUtf8Continuation(uint8_t c) {
return (c & 0xC0) == 0x80;
}

File diff suppressed because it is too large Load Diff

View File

@@ -249,4 +249,4 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
bool SerialBLEInterface::isConnected() const {
return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0;
}
}

View File

@@ -88,4 +88,4 @@ public:
#else
#define BLE_DEBUG_PRINT(...) {}
#define BLE_DEBUG_PRINTLN(...) {}
#endif
#endif

View File

@@ -0,0 +1,70 @@
#pragma once
#include <Arduino.h>
// CPU Frequency Scaling for ESP32-S3
//
// Typical current draw (CPU only, rough):
// 240 MHz ~70-80 mA
// 160 MHz ~50-60 mA
// 80 MHz ~30-40 mA
//
// SPI peripherals and UART use their own clock dividers from the APB clock,
// so LoRa, e-ink, and GPS serial all work fine at 80MHz.
#ifdef ESP32
#ifndef CPU_FREQ_IDLE
#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening
#endif
#ifndef CPU_FREQ_BOOST
#define CPU_FREQ_BOOST 240 // MHz — heavy processing
#endif
#ifndef CPU_BOOST_TIMEOUT_MS
#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds
#endif
class CPUPowerManager {
public:
CPUPowerManager() : _boosted(false), _boost_started(0) {}
void begin() {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
_boosted = false;
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
}
void loop() {
if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) {
setIdle();
}
}
void setBoost() {
if (!_boosted) {
setCpuFrequencyMhz(CPU_FREQ_BOOST);
_boosted = true;
MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST);
}
_boost_started = millis();
}
void setIdle() {
if (_boosted) {
setCpuFrequencyMhz(CPU_FREQ_IDLE);
_boosted = false;
MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE);
}
}
bool isBoosted() const { return _boosted; }
uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); }
private:
bool _boosted;
unsigned long _boost_started;
};
#endif // ESP32

View File

@@ -0,0 +1,185 @@
#pragma once
#include <Arduino.h>
#include "variant.h"
#include "GPSStreamCounter.h"
// GPS Duty Cycle Manager
// Controls the hardware GPS enable pin (PIN_GPS_EN) to save power.
// When enabled, cycles between acquiring a fix and sleeping with power cut.
//
// States:
// OFF User has disabled GPS. Hardware power is cut.
// ACQUIRING GPS module powered on, waiting for a fix or timeout.
// SLEEPING GPS module powered off, timer counting down to next cycle.
#if HAS_GPS
// How long to leave GPS powered on while acquiring a fix (ms)
#ifndef GPS_ACQUIRE_TIMEOUT_MS
#define GPS_ACQUIRE_TIMEOUT_MS 180000 // 3 minutes
#endif
// How long to sleep between acquisition cycles (ms)
#ifndef GPS_SLEEP_DURATION_MS
#define GPS_SLEEP_DURATION_MS 900000 // 15 minutes
#endif
// If we get a fix quickly, power off immediately but still respect
// a minimum on-time so the RTC can sync properly
#ifndef GPS_MIN_ON_TIME_MS
#define GPS_MIN_ON_TIME_MS 5000 // 5 seconds after fix
#endif
enum class GPSDutyState : uint8_t {
OFF = 0, // User-disabled, hardware power off
ACQUIRING, // Hardware on, waiting for fix
SLEEPING // Hardware off, timer running
};
class GPSDutyCycle {
public:
GPSDutyCycle() : _state(GPSDutyState::OFF), _state_entered(0),
_last_fix_time(0), _got_fix(false), _time_synced(false),
_stream(nullptr) {}
// Attach the stream counter so we can reset it on power cycles
void setStreamCounter(GPSStreamCounter* stream) { _stream = stream; }
// Call once in setup() after board.begin() and GPS serial init.
void begin(bool initial_enable) {
if (initial_enable) {
_powerOn();
_setState(GPSDutyState::ACQUIRING);
} else {
_powerOff();
_setState(GPSDutyState::OFF);
}
}
// Call every iteration of loop().
// Returns true if GPS hardware is currently powered on.
bool loop() {
switch (_state) {
case GPSDutyState::OFF:
return false;
case GPSDutyState::ACQUIRING: {
unsigned long elapsed = millis() - _state_entered;
if (_got_fix && elapsed >= GPS_MIN_ON_TIME_MS) {
MESH_DEBUG_PRINTLN("GPS duty: fix acquired, powering off for %u min",
(unsigned)(GPS_SLEEP_DURATION_MS / 60000));
_powerOff();
_setState(GPSDutyState::SLEEPING);
return false;
}
if (elapsed >= GPS_ACQUIRE_TIMEOUT_MS) {
MESH_DEBUG_PRINTLN("GPS duty: acquire timeout (%us), sleeping",
(unsigned)(GPS_ACQUIRE_TIMEOUT_MS / 1000));
_powerOff();
_setState(GPSDutyState::SLEEPING);
return false;
}
return true;
}
case GPSDutyState::SLEEPING: {
if (millis() - _state_entered >= GPS_SLEEP_DURATION_MS) {
MESH_DEBUG_PRINTLN("GPS duty: waking up for next acquisition cycle");
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
return true;
}
return false;
}
}
return false;
}
void notifyFix() {
if (_state == GPSDutyState::ACQUIRING && !_got_fix) {
_got_fix = true;
_last_fix_time = millis();
MESH_DEBUG_PRINTLN("GPS duty: fix notification received");
}
}
void notifyTimeSync() {
_time_synced = true;
}
void enable() {
if (_state == GPSDutyState::OFF) {
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
MESH_DEBUG_PRINTLN("GPS duty: enabled, starting acquisition");
}
}
void disable() {
_powerOff();
_setState(GPSDutyState::OFF);
_got_fix = false;
MESH_DEBUG_PRINTLN("GPS duty: disabled, power off");
}
void forceWake() {
if (_state == GPSDutyState::SLEEPING) {
_got_fix = false;
_powerOn();
_setState(GPSDutyState::ACQUIRING);
MESH_DEBUG_PRINTLN("GPS duty: forced wake for user request");
}
}
GPSDutyState getState() const { return _state; }
bool isHardwareOn() const { return _state == GPSDutyState::ACQUIRING; }
bool hadFix() const { return _got_fix; }
bool hasTimeSynced() const { return _time_synced; }
uint32_t sleepRemainingSecs() const {
if (_state != GPSDutyState::SLEEPING) return 0;
unsigned long elapsed = millis() - _state_entered;
if (elapsed >= GPS_SLEEP_DURATION_MS) return 0;
return (GPS_SLEEP_DURATION_MS - elapsed) / 1000;
}
uint32_t acquireElapsedSecs() const {
if (_state != GPSDutyState::ACQUIRING) return 0;
return (millis() - _state_entered) / 1000;
}
private:
void _powerOn() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
delay(10);
#endif
if (_stream) _stream->resetCounters();
}
void _powerOff() {
#ifdef PIN_GPS_EN
digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE);
#endif
}
void _setState(GPSDutyState s) {
_state = s;
_state_entered = millis();
}
GPSDutyState _state;
unsigned long _state_entered;
unsigned long _last_fix_time;
bool _got_fix;
bool _time_synced;
GPSStreamCounter* _stream;
};
#endif // HAS_GPS

View File

@@ -0,0 +1,72 @@
#pragma once
#include <Arduino.h>
// Transparent Stream wrapper that counts NMEA sentences (newline-delimited)
// flowing from the GPS serial port to the MicroNMEA parser.
//
// Usage: Instead of MicroNMEALocationProvider gps(Serial2, &rtc_clock);
// Use: GPSStreamCounter gpsStream(Serial2);
// MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
//
// Every read() call passes through to the underlying stream; when a '\n'
// is seen the sentence counter increments. This lets the UI display a
// live "nmea" count so users can confirm the baud rate is correct and
// the GPS module is actually sending data.
class GPSStreamCounter : public Stream {
public:
GPSStreamCounter(Stream& inner)
: _inner(inner), _sentences(0), _sentences_snapshot(0),
_last_snapshot(0), _sentences_per_sec(0) {}
// --- Stream read interface (passes through) ---
int available() override { return _inner.available(); }
int peek() override { return _inner.peek(); }
int read() override {
int c = _inner.read();
if (c == '\n') {
_sentences++;
}
return c;
}
// --- Stream write interface (pass through for NMEA commands if needed) ---
size_t write(uint8_t b) override { return _inner.write(b); }
// --- Sentence counting API ---
// Total sentences received since boot (or last reset)
uint32_t getSentenceCount() const { return _sentences; }
// Sentences received per second (updated each time you call it,
// with a 1-second rolling window)
uint16_t getSentencesPerSec() {
unsigned long now = millis();
unsigned long elapsed = now - _last_snapshot;
if (elapsed >= 1000) {
uint32_t delta = _sentences - _sentences_snapshot;
// Scale to per-second if interval wasn't exactly 1000ms
_sentences_per_sec = (uint16_t)((delta * 1000UL) / elapsed);
_sentences_snapshot = _sentences;
_last_snapshot = now;
}
return _sentences_per_sec;
}
// Reset all counters (e.g. when GPS hardware power cycles)
void resetCounters() {
_sentences = 0;
_sentences_snapshot = 0;
_sentences_per_sec = 0;
_last_snapshot = millis();
}
private:
Stream& _inner;
volatile uint32_t _sentences;
uint32_t _sentences_snapshot;
unsigned long _last_snapshot;
uint16_t _sentences_per_sec;
};

View File

@@ -72,10 +72,11 @@ void TDeckBoard::begin() {
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
}
// Test BQ27220 communication
// Test BQ27220 communication and configure design capacity
#if HAS_BQ27220
uint16_t voltage = getBattMilliVolts();
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
configureFuelGauge();
#endif
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
@@ -123,4 +124,233 @@ uint8_t TDeckBoard::getBatteryPercent() {
#else
return 0;
#endif
}
// ---- BQ27220 extended register helpers ----
#if HAS_BQ27220
// Read a 16-bit register from BQ27220. Returns 0 on I2C error.
static uint16_t bq27220_read16(uint8_t reg) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0;
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2) != 2) return 0;
uint16_t val = Wire.read();
val |= (Wire.read() << 8);
return val;
}
// Read a single byte from BQ27220 register.
static uint8_t bq27220_read8(uint8_t reg) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0;
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)1) != 1) return 0;
return Wire.read();
}
// Write a 16-bit subcommand to BQ27220 Control register (0x00).
// Subcommands control unsealing, config mode, sealing, etc.
static bool bq27220_writeControl(uint16_t subcmd) {
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x00); // Control register
Wire.write(subcmd & 0xFF); // LSB first
Wire.write((subcmd >> 8) & 0xFF); // MSB
return Wire.endTransmission() == 0;
}
#endif
// ---- BQ27220 Design Capacity configuration ----
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 1400 mAh
// cell. This function checks on boot and writes the correct value via the
// MAC Data Memory interface if needed. The value persists in battery-backed
// RAM, so this typically only writes once (or after a full battery disconnect).
//
// Procedure follows TI TRM SLUUBD4A Section 6.1:
// 1. Unseal → 2. Full Access → 3. Enter CFG_UPDATE
// 4. Write Design Capacity via MAC → 5. Exit CFG_UPDATE → 6. Seal
bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
#if HAS_BQ27220
// Read current design capacity from standard command register
uint16_t currentDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
if (currentDC == designCapacity_mAh) {
Serial.println("BQ27220: Design Capacity already correct, skipping");
return true;
}
Serial.printf("BQ27220: Updating Design Capacity from %d to %d mAh\n", currentDC, designCapacity_mAh);
// Step 1: Unseal (default unseal keys)
bq27220_writeControl(0x0414);
delay(2);
bq27220_writeControl(0x3672);
delay(2);
// Step 2: Enter Full Access mode
bq27220_writeControl(0xFFFF);
delay(2);
bq27220_writeControl(0xFFFF);
delay(2);
// Step 3: Enter CFG_UPDATE mode
bq27220_writeControl(0x0090);
// Wait for CFGUPMODE bit (bit 10) in OperationStatus register
bool cfgReady = false;
for (int i = 0; i < 50; i++) {
delay(20);
uint16_t opStatus = bq27220_read16(BQ27220_REG_OP_STATUS);
Serial.printf("BQ27220: OperationStatus = 0x%04X (attempt %d)\n", opStatus, i);
if (opStatus & 0x0400) { // CFGUPMODE is bit 10
cfgReady = true;
break;
}
}
if (!cfgReady) {
Serial.println("BQ27220: ERROR - Timeout waiting for CFGUPDATE mode");
bq27220_writeControl(0x0092); // Try to exit cleanly
bq27220_writeControl(0x0030); // Re-seal
return false;
}
Serial.println("BQ27220: Entered CFGUPDATE mode");
// Step 4: Write Design Capacity via MAC Data Memory interface
// Design Capacity mAh lives at data memory address 0x929F
// 4a. Select the data memory block by writing address to 0x3E-0x3F
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); // MACDataControl register
Wire.write(0x9F); // Address low byte
Wire.write(0x92); // Address high byte
Wire.endTransmission();
delay(10);
// 4b. Read old data (MSB, LSB) and checksum for differential update
uint8_t oldMSB = bq27220_read8(0x40);
uint8_t oldLSB = bq27220_read8(0x41);
uint8_t oldChksum = bq27220_read8(0x60);
uint8_t dataLen = bq27220_read8(0x61);
Serial.printf("BQ27220: Old DC bytes=0x%02X 0x%02X chk=0x%02X len=%d\n",
oldMSB, oldLSB, oldChksum, dataLen);
// 4c. Compute new values (BQ27220 stores big-endian in data memory)
uint8_t newMSB = (designCapacity_mAh >> 8) & 0xFF;
uint8_t newLSB = designCapacity_mAh & 0xFF;
// Differential checksum: remove old bytes, add new bytes
uint8_t temp = (255 - oldChksum - oldMSB - oldLSB);
uint8_t newChksum = 255 - ((temp + newMSB + newLSB) & 0xFF);
Serial.printf("BQ27220: New DC bytes=0x%02X 0x%02X chk=0x%02X\n",
newMSB, newLSB, newChksum);
// 4d. Write address + new data as a single block transaction
// BQ27220 MAC requires: [0x3E] [addr_lo] [addr_hi] [data...]
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E); // Start at MACDataControl
Wire.write(0x9F); // Address low byte
Wire.write(0x92); // Address high byte
Wire.write(newMSB); // Data byte 0 (at 0x40)
Wire.write(newLSB); // Data byte 1 (at 0x41)
uint8_t writeResult = Wire.endTransmission();
Serial.printf("BQ27220: Write block result = %d\n", writeResult);
// 4e. Write updated checksum and length
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x60);
Wire.write(newChksum);
Wire.write(dataLen);
writeResult = Wire.endTransmission();
Serial.printf("BQ27220: Write checksum result = %d\n", writeResult);
delay(10);
// 4f. Verify the write took effect before exiting config mode
// Re-read the block to confirm
Wire.beginTransmission(BQ27220_I2C_ADDR);
Wire.write(0x3E);
Wire.write(0x9F);
Wire.write(0x92);
Wire.endTransmission();
delay(10);
uint8_t verMSB = bq27220_read8(0x40);
uint8_t verLSB = bq27220_read8(0x41);
Serial.printf("BQ27220: Verify in CFGUPDATE: DC bytes=0x%02X 0x%02X (%d mAh)\n",
verMSB, verLSB, (verMSB << 8) | verLSB);
// Step 5: Exit CFG_UPDATE (with reinit to apply changes immediately)
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
delay(200); // Allow gauge to reinitialize
// Verify
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
Serial.printf("BQ27220: Design Capacity now reads %d mAh (expected %d)\n",
verifyDC, designCapacity_mAh);
if (verifyDC == designCapacity_mAh) {
Serial.println("BQ27220: Configuration SUCCESS");
} else {
Serial.println("BQ27220: Configuration FAILED");
}
// Step 7: Seal the device
bq27220_writeControl(0x0030);
delay(5);
return verifyDC == designCapacity_mAh;
#else
return false;
#endif
}
int16_t TDeckBoard::getAvgCurrent() {
#if HAS_BQ27220
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
#else
return 0;
#endif
}
int16_t TDeckBoard::getAvgPower() {
#if HAS_BQ27220
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getTimeToEmpty() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
#else
return 0xFFFF;
#endif
}
uint16_t TDeckBoard::getRemainingCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getFullChargeCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_FULL_CAP);
#else
return 0;
#endif
}
uint16_t TDeckBoard::getDesignCapacity() {
#if HAS_BQ27220
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
#else
return 0;
#endif
}

View File

@@ -7,11 +7,23 @@
#include <driver/rtc_io.h>
// BQ27220 Fuel Gauge Registers
#define BQ27220_REG_VOLTAGE 0x08
#define BQ27220_REG_CURRENT 0x0C
#define BQ27220_REG_SOC 0x2C
#define BQ27220_REG_VOLTAGE 0x08
#define BQ27220_REG_CURRENT 0x0C // Instantaneous current (mA, signed)
#define BQ27220_REG_SOC 0x2C
#define BQ27220_REG_REMAIN_CAP 0x10 // Remaining capacity (mAh)
#define BQ27220_REG_FULL_CAP 0x12 // Full charge capacity (mAh)
#define BQ27220_REG_AVG_CURRENT 0x14 // Average current (mA, signed)
#define BQ27220_REG_TIME_TO_EMPTY 0x16 // Minutes until empty
#define BQ27220_REG_AVG_POWER 0x24 // Average power (mW, signed)
#define BQ27220_REG_DESIGN_CAP 0x3C // Design capacity (mAh, read-only standard cmd)
#define BQ27220_REG_OP_STATUS 0x3A // Operation status
#define BQ27220_I2C_ADDR 0x55
// T-Deck Pro battery capacity (all variants use 1400 mAh cell)
#ifndef BQ27220_DESIGN_CAPACITY_MAH
#define BQ27220_DESIGN_CAPACITY_MAH 1400
#endif
class TDeckBoard : public ESP32Board {
public:
void begin();
@@ -52,6 +64,27 @@ public:
// Read state of charge percentage from BQ27220
uint8_t getBatteryPercent();
// Read average current in mA (negative = discharging, positive = charging)
int16_t getAvgCurrent();
// Read average power in mW (negative = discharging, positive = charging)
int16_t getAvgPower();
// Read time-to-empty in minutes (0xFFFF if charging/unavailable)
uint16_t getTimeToEmpty();
// Read remaining capacity in mAh
uint16_t getRemainingCapacity();
// Read full charge capacity in mAh (learned value, may need cycling to update)
uint16_t getFullChargeCapacity();
// Read design capacity in mAh (the configured battery size)
uint16_t getDesignCapacity();
// Configure BQ27220 design capacity (checks on boot, writes only if wrong)
bool configureFuelGauge(uint16_t designCapacity_mAh = BQ27220_DESIGN_CAPACITY_MAH);
const char* getManufacturerName() const {
return "LilyGo T-Deck Pro";
}

View File

@@ -28,7 +28,10 @@ private:
uint8_t _addr;
TwoWire* _wire;
bool _initialized;
bool _shiftActive; // Sticky shift (one-shot)
bool _shiftActive; // Sticky shift (one-shot or held)
bool _shiftConsumed; // Was shift active for the last returned key
bool _shiftHeld; // Shift key physically held down
bool _shiftUsedWhileHeld; // Was shift consumed by any key while held
bool _altActive; // Sticky alt (one-shot)
bool _symActive; // Sticky sym (one-shot)
unsigned long _lastShiftTime; // For Shift+key combos
@@ -148,7 +151,7 @@ private:
public:
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
: _addr(addr), _wire(wire), _initialized(false),
_shiftActive(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
bool begin() {
// Check if device responds
@@ -203,6 +206,19 @@ public:
Serial.printf("KB raw: event=0x%02X code=%d pressed=%d count=%d\n",
keyEvent, keyCode, pressed, keyCount);
// Track shift release (before the general release-ignore)
if (!pressed && (keyCode == 35 || keyCode == 31)) {
_shiftHeld = false;
// If shift was used while held (e.g. cursor nav), clear it completely
// so the next bare keypress isn't treated as shifted.
// If shift was NOT used (tap-then-release), keep _shiftActive for one-shot.
if (_shiftUsedWhileHeld) {
_shiftActive = false;
}
_shiftUsedWhileHeld = false;
return 0;
}
// Only act on key press, not release
if (!pressed || keyCode == 0) {
return 0;
@@ -211,6 +227,8 @@ public:
// Handle modifier keys - set sticky state and return 0
if (keyCode == 35 || keyCode == 31) { // Shift keys
_shiftActive = true;
_shiftHeld = true;
_shiftUsedWhileHeld = false;
_lastShiftTime = millis();
Serial.println("KB: Shift activated");
return 0;
@@ -276,7 +294,17 @@ public:
if (c >= 'a' && c <= 'z') {
c = c - 'a' + 'A';
}
_shiftActive = false; // Reset sticky shift
// Track that shift was used while physically held
if (_shiftHeld) {
_shiftUsedWhileHeld = true;
}
// Only clear shift if it's one-shot (tap), not held down
if (!_shiftHeld) {
_shiftActive = false;
}
_shiftConsumed = true; // Record that shift was active for this key
} else {
_shiftConsumed = false;
}
if (c != 0) {
@@ -294,4 +322,10 @@ public:
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
return (millis() - _lastShiftTime) < withinMs;
}
// Check if shift was active when the most recent key was produced
// (immune to e-ink refresh timing unlike wasShiftRecentlyPressed)
bool wasShiftConsumed() const {
return _shiftConsumed;
}
};

View File

@@ -80,6 +80,8 @@ build_flags =
-D PIN_DISPLAY_BL=45
-D PIN_USER_BTN=0
-D CST328_PIN_RST=38
-D FIRMWARE_VERSION='"Meck v0.9.1A"'
-D ARDUINO_LOOP_STACK_SIZE=32768
build_src_filter = ${esp32_base.build_src_filter}
+<../variants/LilyGo_TDeck_Pro>
+<helpers/sensors/*.cpp>
@@ -126,6 +128,40 @@ build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
[env:LilyGo_TDeck_Pro_standalone]
extends = LilyGo_TDeck_Pro
build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=700
-D MAX_GROUP_CHANNELS=20
-D OFFLINE_QUEUE_SIZE=256
-D NO_OTA=1
-D FIRMWARE_VERSION='"Meck v0.9.1A-NB"'
; === NO BLE_PIN_CODE, NO WIFI_SSID ===
; By omitting these, the preprocessor selects ArduinoSerialInterface (USB only).
; The BLE stack is never initialized, never linked, and the ESP32-S3 BT/WiFi
; radio hardware stays in its boot-default OFF state — zero RF power draw.
; This is the ONLY reliable way to fully disable BLE on the ESP32-S3.
; Calling esp_bt_controller_disable() after init does NOT fully power off the radio.
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
lib_deps =
${LilyGo_TDeck_Pro.lib_deps}
densaugeo/base64 @ ~1.4.0
https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6
bitbank2/JPEGDEC
; Audio libs included because AudiobookPlayerScreen.h unconditionally #includes
; Audio.h when LilyGo_TDeck_Pro is defined. No power cost — the Audio object is
; only heap-allocated when user presses 'P'. Without BLE competing for heap,
; audiobook playback actually works better in standalone mode.
[env:LilyGo_TDeck_Pro_repeater]
extends = LilyGo_TDeck_Pro

View File

@@ -17,7 +17,10 @@ ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if HAS_GPS
MicroNMEALocationProvider gps(Serial2, &rtc_clock);
// Wrap Serial2 with a sentence counter so the UI can show NMEA throughput.
// MicroNMEALocationProvider reads through this wrapper transparently.
GPSStreamCounter gpsStream(Serial2);
MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#else
SensorManager sensors;

View File

@@ -18,6 +18,7 @@
#if HAS_GPS
#include "helpers/sensors/EnvironmentSensorManager.h"
#include "helpers/sensors/MicroNMEALocationProvider.h"
#include "GPSStreamCounter.h"
#else
#include <helpers/SensorManager.h>
#endif
@@ -27,6 +28,7 @@ extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
#if HAS_GPS
extern GPSStreamCounter gpsStream;
extern EnvironmentSensorManager sensors;
#else
extern SensorManager sensors;