mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67cb93e643 | ||
|
|
51f770ba74 | ||
|
|
65c7bff963 | ||
|
|
e056ea3c2c | ||
|
|
b2967fc1a7 | ||
|
|
addcbcd00e | ||
|
|
8cdf19a848 | ||
|
|
5019d12fb0 | ||
|
|
306e9815b4 | ||
|
|
0a892f2dad | ||
|
|
b1e3f2ac28 | ||
|
|
4683711877 | ||
|
|
9610277b83 | ||
|
|
745efc4cc1 | ||
|
|
7223395740 | ||
|
|
9ef1fa4f1b | ||
|
|
2dd5c4f59f | ||
|
|
ee2a27258b | ||
|
|
5b868d51ca | ||
|
|
220006c229 | ||
|
|
a60f4146d5 | ||
|
|
017b170e81 | ||
|
|
9b0c13fd4c | ||
|
|
5e3a252748 | ||
|
|
6c3fb569f4 | ||
|
|
fa747bfce2 | ||
|
|
f0dc218a57 | ||
|
|
a23b65730a | ||
|
|
569794d2fe | ||
|
|
ea1ca315b8 | ||
|
|
83b3ea6275 | ||
|
|
9c6d5138b0 | ||
|
|
15165bb429 | ||
|
|
c4b9952d95 | ||
|
|
ce37bf6b90 | ||
|
|
8e98132506 | ||
|
|
33c2758a87 | ||
|
|
f644892b07 | ||
|
|
8f558b130f | ||
|
|
04462b93bc | ||
|
|
d42c283fb4 | ||
|
|
87a5f185d3 | ||
|
|
2972d1ffb4 | ||
|
|
fe1c1931ab |
127
README.md
127
README.md
@@ -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,36 +38,117 @@ 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)
|
||||
|
||||
BLE is **disabled by default** at boot to support standalone-first operation. The device is fully functional without a phone — you can send and receive messages, browse contacts, read e-books, and set your timezone directly from the keyboard.
|
||||
|
||||
To connect to the MeshCore companion app, navigate to the **Bluetooth** home page (use D to page through) and press **Enter** to toggle BLE on. The BLE PIN will be displayed on screen. Toggle it off again the same way when you're done.
|
||||
|
||||
### Clock & Timezone
|
||||
|
||||
The T-Deck Pro does not include a dedicated RTC chip, so after each reboot the device clock starts unset. The clock will appear in the nav bar (between node name and battery) once the time has been synced by one of two methods:
|
||||
|
||||
1. **GPS fix** (standalone) — Once the GPS acquires a satellite fix, the time is automatically synced from the NMEA data. No phone or BLE connection required. Typical time to first fix is 30–90 seconds outdoors with clear sky.
|
||||
2. **BLE companion app** — If BLE is enabled and connected to the MeshCore companion app, the app will push the current time to the device.
|
||||
|
||||
**Setting your timezone:**
|
||||
|
||||
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 |
|
||||
|-----|--------|
|
||||
| W | Increase offset (+1 hour) |
|
||||
| S | Decrease offset (-1 hour) |
|
||||
| Enter | Save and exit |
|
||||
| Q | Cancel and exit |
|
||||
|
||||
The UTC offset is persisted to flash and survives reboots — you only need to set it once. The valid range is UTC-12 to UTC+14. For example, AEST is UTC+10 and AEDT is UTC+11.
|
||||
|
||||
The GPS page also shows the current time, satellite count, position, altitude, and your configured UTC offset for reference.
|
||||
|
||||
### Channel Message Screen
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| 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 / C | Open DM compose to selected chat contact |
|
||||
| Enter | Open DM compose (Chat contact) or repeater admin (Repeater 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
|
||||
|
||||
Select a **Repeater** contact in the contacts list and press **Enter** to open the repeater admin screen. You'll be prompted for the repeater's admin password. Characters briefly appear as you type them before being masked, making it easier to enter symbols and numbers on the T-Deck Pro keyboard.
|
||||
|
||||
After a successful login, you'll see a menu with the following remote administration commands:
|
||||
|
||||
| Menu Item | Description |
|
||||
|-----------|-------------|
|
||||
| Clock Sync | Push your device's clock time to the repeater |
|
||||
| Send Advert | Trigger the repeater to broadcast an advertisement |
|
||||
| Neighbors | View other repeaters heard via zero-hop adverts |
|
||||
| Get Clock | Read the repeater's current clock value |
|
||||
| Version | Query the repeater's firmware version |
|
||||
| Get Status | Retrieve repeater status information |
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| W / S | Navigate menu items |
|
||||
| Enter | Execute selected 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 (5–12), Enter to confirm |
|
||||
| Coding Rate | W / S to adjust (5–8), Enter to confirm |
|
||||
| TX Power | W / S to adjust (1–20 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
|
||||
|
||||
@@ -146,6 +253,8 @@ Download a copy of the Meck firmware bin from https://github.com/pelgraine/Meck/
|
||||
|
||||
The companion firmware can be connected to via BLE. USB is planned for a future update.
|
||||
|
||||
> **Note:** On the T-Deck Pro, BLE is disabled by default at boot. Navigate to the Bluetooth home page and press Enter to enable BLE before connecting with a companion app.
|
||||
|
||||
- Web: https://app.meshcore.nz
|
||||
- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android
|
||||
- iOS: https://apps.apple.com/us/app/meshcore/id6742354151?platform=iphone
|
||||
@@ -178,7 +287,9 @@ There are a number of fairly major features in the pipeline, with no particular
|
||||
- [X] View and compose all channel messages Companion BLE firmware
|
||||
- [X] Standalone DM functionality for Companion BLE firmware
|
||||
- [X] Contacts list with filtering for Companion BLE firmware
|
||||
- [ ] Standalone repeater admin access 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,4 +46,8 @@ public:
|
||||
virtual void showAlert(const char* text, int duration_millis) {}
|
||||
virtual void forceRefresh() {}
|
||||
virtual void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {}
|
||||
|
||||
// Repeater admin callbacks (from MyMesh)
|
||||
virtual void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {}
|
||||
virtual void onAdminCliResponse(const char* from_name, const char* text) {}
|
||||
};
|
||||
@@ -228,6 +228,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
||||
file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
|
||||
file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.read((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -263,6 +264,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
||||
file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85
|
||||
file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86
|
||||
file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87
|
||||
file.write((uint8_t *)&_prefs.utc_offset_hours, sizeof(_prefs.utc_offset_hours)); // 88
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -598,4 +600,4 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src
|
||||
}
|
||||
return false; // error
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -476,7 +476,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
||||
}
|
||||
}
|
||||
|
||||
return false; // never filter — let normal processing continue
|
||||
return false; // never filter  let normal processing continue
|
||||
}
|
||||
|
||||
void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
@@ -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));
|
||||
@@ -950,6 +1020,7 @@ void MyMesh::begin(bool has_display) {
|
||||
_prefs.buzzer_quiet = constrain(_prefs.buzzer_quiet, 0, 1); // Ensure boolean 0 or 1
|
||||
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
|
||||
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
|
||||
_prefs.utc_offset_hours = constrain(_prefs.utc_offset_hours, -12, 14); // Valid timezone range
|
||||
|
||||
#ifdef BLE_PIN_CODE // 123456 by default
|
||||
if (_prefs.ble_pin == 0) {
|
||||
@@ -1733,6 +1804,12 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
savePrefs();
|
||||
}
|
||||
#endif
|
||||
// UTC offset for local clock display (works regardless of GPS)
|
||||
if (strcmp(sp, "utc_offset") == 0) {
|
||||
int offset = atoi(np);
|
||||
_prefs.utc_offset_hours = constrain(offset, -12, 14);
|
||||
savePrefs();
|
||||
}
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "10 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "15 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.8"
|
||||
#define FIRMWARE_VERSION "Meck v0.8.9"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
@@ -108,6 +108,12 @@ public:
|
||||
// Send a direct message from the UI (no BLE dependency)
|
||||
bool uiSendDirectMessage(uint32_t contact_idx, const char* text);
|
||||
|
||||
// Repeater admin - UI-initiated operations
|
||||
bool uiLoginToRepeater(uint32_t contact_idx, const char* password);
|
||||
bool uiSendCliCommand(uint32_t contact_idx, const char* command);
|
||||
int getAdminContactIdx() const { return _admin_contact_idx; }
|
||||
|
||||
|
||||
protected:
|
||||
float getAirtimeBudgetFactor() const override;
|
||||
int getInterferenceThreshold() const override;
|
||||
@@ -164,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();
|
||||
@@ -183,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;
|
||||
@@ -247,6 +255,7 @@ private:
|
||||
};
|
||||
SentMsgTrack _sent_track[SENT_TRACK_SIZE];
|
||||
int _sent_track_idx; // next slot in circular buffer
|
||||
int _admin_contact_idx; // contact index for active admin session (-1 if none)
|
||||
};
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
@@ -28,4 +28,6 @@ struct NodePrefs { // persisted to file
|
||||
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
|
||||
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)
|
||||
};
|
||||
@@ -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,120 @@
|
||||
|
||||
// Text reader mode state
|
||||
static bool readerMode = false;
|
||||
|
||||
// Notes mode state
|
||||
static bool notesMode = 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!
|
||||
@@ -289,7 +403,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 +488,82 @@ 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);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// CPU frequency scaling  drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
// T-Deck Pro: BLE starts disabled for standalone-first operation
|
||||
// User can toggle it on from the Bluetooth home page (Enter or long-press)
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE)
|
||||
serial_interface.disable();
|
||||
MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)");
|
||||
#endif
|
||||
|
||||
MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ===");
|
||||
@@ -384,26 +571,54 @@ void setup() {
|
||||
|
||||
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();
|
||||
#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 mode state for key routing
|
||||
readerMode = ui_task.isOnTextReader();
|
||||
notesMode = ui_task.isOnNotesScreen();
|
||||
#else
|
||||
ui_task.loop();
|
||||
#endif
|
||||
@@ -540,7 +755,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--;
|
||||
@@ -559,7 +774,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;
|
||||
@@ -599,7 +814,7 @@ void handleKeyboardInput() {
|
||||
|
||||
// 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');
|
||||
@@ -612,105 +827,272 @@ 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 '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
|
||||
@@ -721,7 +1103,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
|
||||
@@ -733,6 +1114,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();
|
||||
@@ -747,20 +1129,35 @@ 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':
|
||||
// Go back to home screen
|
||||
// Go back to home screen (admin mode handled above)
|
||||
Serial.println("Nav: Back to home");
|
||||
ui_task.gotoHomeScreen();
|
||||
break;
|
||||
@@ -770,6 +1167,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);
|
||||
@@ -918,6 +1320,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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ private:
|
||||
}
|
||||
}
|
||||
// Sort by last_advert_timestamp descending (most recently seen first)
|
||||
// Simple insertion sort — fine for up to 400 entries on ESP32
|
||||
// Simple insertion sort — fine for up to 400 entries on ESP32
|
||||
for (int i = 1; i < _filteredCount; i++) {
|
||||
uint16_t tmpIdx = _filteredIdx[i];
|
||||
uint32_t tmpTs = _filteredTs[i];
|
||||
@@ -180,8 +180,12 @@ public:
|
||||
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
|
||||
display.print(tmp);
|
||||
|
||||
// Count on right
|
||||
snprintf(tmp, sizeof(tmp), "%d/%d", _filteredCount, (int)the_mesh.getNumContacts());
|
||||
// Count on right: All → total/max, filtered → matched/total
|
||||
if (_filter == FILTER_ALL) {
|
||||
snprintf(tmp, sizeof(tmp), "%d/%d", (int)the_mesh.getNumContacts(), MAX_CONTACTS);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%d/%d", _filteredCount, (int)the_mesh.getNumContacts());
|
||||
}
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (& < > " ' &#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 ' ';
|
||||
}
|
||||
1299
examples/companion_radio/ui-new/Notesscreen.h
Normal file
1299
examples/companion_radio/ui-new/Notesscreen.h
Normal file
File diff suppressed because it is too large
Load Diff
619
examples/companion_radio/ui-new/Repeateradminscreen.h
Normal file
619
examples/companion_radio/ui-new/Repeateradminscreen.h
Normal file
@@ -0,0 +1,619 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
class MyMesh;
|
||||
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:
|
||||
enum AdminState {
|
||||
STATE_PASSWORD_ENTRY, // Typing admin password
|
||||
STATE_LOGGING_IN, // Waiting for login response
|
||||
STATE_MENU, // Main admin menu
|
||||
STATE_COMMAND_PENDING, // Waiting for CLI response
|
||||
STATE_RESPONSE_VIEW, // Displaying CLI response
|
||||
STATE_ERROR // Error state (timeout, send fail)
|
||||
};
|
||||
|
||||
// Menu items
|
||||
enum MenuItem {
|
||||
MENU_CLOCK_SYNC = 0,
|
||||
MENU_ADVERT,
|
||||
MENU_NEIGHBORS,
|
||||
MENU_GET_CLOCK,
|
||||
MENU_GET_VER,
|
||||
MENU_GET_STATUS,
|
||||
MENU_COUNT
|
||||
};
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
|
||||
AdminState _state;
|
||||
int _contactIdx; // Contact table index of the repeater
|
||||
char _repeaterName[32]; // Cached repeater name
|
||||
uint8_t _permissions; // Login permissions (0=guest, 3=admin)
|
||||
uint32_t _serverTime; // Server timestamp from login response
|
||||
|
||||
// Password entry
|
||||
char _password[ADMIN_PASSWORD_MAX];
|
||||
int _pwdLen;
|
||||
unsigned long _lastCharAt; // millis() when last char typed (for brief reveal)
|
||||
|
||||
// Menu
|
||||
int _menuSel; // Currently selected menu item
|
||||
|
||||
// Response buffer
|
||||
char _response[ADMIN_RESPONSE_MAX];
|
||||
int _responseLen;
|
||||
int _responseScroll; // Scroll offset for long responses
|
||||
|
||||
// Timing
|
||||
unsigned long _cmdSentAt; // millis() when command was sent
|
||||
bool _waitingForLogin;
|
||||
|
||||
// Password cache - remembers passwords per repeater within session
|
||||
static const int PWD_CACHE_SIZE = 8;
|
||||
struct PwdCacheEntry {
|
||||
int contactIdx;
|
||||
char password[ADMIN_PASSWORD_MAX];
|
||||
};
|
||||
PwdCacheEntry _pwdCache[PWD_CACHE_SIZE];
|
||||
int _pwdCacheCount;
|
||||
|
||||
// Look up cached password for a contact, returns nullptr if not found
|
||||
const char* getCachedPassword(int contactIdx) {
|
||||
for (int i = 0; i < _pwdCacheCount; i++) {
|
||||
if (_pwdCache[i].contactIdx == contactIdx) return _pwdCache[i].password;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Save password to cache (update existing or add new, evict oldest if full)
|
||||
void cachePassword(int contactIdx, const char* pwd) {
|
||||
// Update existing entry
|
||||
for (int i = 0; i < _pwdCacheCount; i++) {
|
||||
if (_pwdCache[i].contactIdx == contactIdx) {
|
||||
strncpy(_pwdCache[i].password, pwd, ADMIN_PASSWORD_MAX - 1);
|
||||
_pwdCache[i].password[ADMIN_PASSWORD_MAX - 1] = '\0';
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Add new entry, evict oldest if full
|
||||
if (_pwdCacheCount < PWD_CACHE_SIZE) {
|
||||
int slot = _pwdCacheCount++;
|
||||
_pwdCache[slot].contactIdx = contactIdx;
|
||||
strncpy(_pwdCache[slot].password, pwd, ADMIN_PASSWORD_MAX - 1);
|
||||
_pwdCache[slot].password[ADMIN_PASSWORD_MAX - 1] = '\0';
|
||||
} else {
|
||||
// Shift entries down to evict oldest
|
||||
for (int i = 0; i < PWD_CACHE_SIZE - 1; i++) {
|
||||
_pwdCache[i] = _pwdCache[i + 1];
|
||||
}
|
||||
_pwdCache[PWD_CACHE_SIZE - 1].contactIdx = contactIdx;
|
||||
strncpy(_pwdCache[PWD_CACHE_SIZE - 1].password, pwd, ADMIN_PASSWORD_MAX - 1);
|
||||
_pwdCache[PWD_CACHE_SIZE - 1].password[ADMIN_PASSWORD_MAX - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
static const char* menuLabel(MenuItem m) {
|
||||
switch (m) {
|
||||
case MENU_CLOCK_SYNC: return "Clock Sync";
|
||||
case MENU_ADVERT: return "Send Advert";
|
||||
case MENU_NEIGHBORS: return "Neighbors";
|
||||
case MENU_GET_CLOCK: return "Get Clock";
|
||||
case MENU_GET_VER: return "Version";
|
||||
case MENU_GET_STATUS: return "Get Status";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static const char* menuCommand(MenuItem m) {
|
||||
switch (m) {
|
||||
case MENU_CLOCK_SYNC: return "clock sync";
|
||||
case MENU_ADVERT: return "advert";
|
||||
case MENU_NEIGHBORS: return "neighbors";
|
||||
case MENU_GET_CLOCK: return "clock";
|
||||
case MENU_GET_VER: return "ver";
|
||||
case MENU_GET_STATUS: return "get status";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Format epoch as HH:MM:SS
|
||||
static void formatTime(char* buf, size_t bufLen, uint32_t epoch) {
|
||||
if (epoch == 0) {
|
||||
strncpy(buf, "--:--:--", bufLen);
|
||||
return;
|
||||
}
|
||||
uint32_t secs = epoch % 60;
|
||||
uint32_t mins = (epoch / 60) % 60;
|
||||
uint32_t hrs = (epoch / 3600) % 24;
|
||||
snprintf(buf, bufLen, "%02d:%02d:%02d", (int)hrs, (int)mins, (int)secs);
|
||||
}
|
||||
|
||||
public:
|
||||
RepeaterAdminScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), _state(STATE_PASSWORD_ENTRY),
|
||||
_contactIdx(-1), _permissions(0), _serverTime(0),
|
||||
_pwdLen(0), _lastCharAt(0), _menuSel(0),
|
||||
_responseLen(0), _responseScroll(0),
|
||||
_cmdSentAt(0), _waitingForLogin(false), _pwdCacheCount(0) {
|
||||
_password[0] = '\0';
|
||||
_repeaterName[0] = '\0';
|
||||
_response[0] = '\0';
|
||||
}
|
||||
|
||||
// Called when entering the screen for a specific repeater contact
|
||||
void openForContact(int contactIdx, const char* name) {
|
||||
_contactIdx = contactIdx;
|
||||
strncpy(_repeaterName, name, sizeof(_repeaterName) - 1);
|
||||
_repeaterName[sizeof(_repeaterName) - 1] = '\0';
|
||||
|
||||
// Reset state
|
||||
_state = STATE_PASSWORD_ENTRY;
|
||||
_lastCharAt = 0;
|
||||
_menuSel = 0;
|
||||
_permissions = 0;
|
||||
_serverTime = 0;
|
||||
_responseLen = 0;
|
||||
_responseScroll = 0;
|
||||
_response[0] = '\0';
|
||||
_waitingForLogin = false;
|
||||
|
||||
// Pre-fill from password cache if available
|
||||
const char* cached = getCachedPassword(contactIdx);
|
||||
if (cached) {
|
||||
strncpy(_password, cached, ADMIN_PASSWORD_MAX - 1);
|
||||
_password[ADMIN_PASSWORD_MAX - 1] = '\0';
|
||||
_pwdLen = strlen(_password);
|
||||
} else {
|
||||
_pwdLen = 0;
|
||||
_password[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
int getContactIdx() const { return _contactIdx; }
|
||||
AdminState getState() const { return _state; }
|
||||
|
||||
// Called by UITask when a login response is received
|
||||
void onLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
_waitingForLogin = false;
|
||||
if (success) {
|
||||
_permissions = permissions;
|
||||
_serverTime = server_time;
|
||||
_state = STATE_MENU;
|
||||
cachePassword(_contactIdx, _password); // remember for next time
|
||||
} else {
|
||||
snprintf(_response, sizeof(_response), "Login failed.\nCheck password.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
// Called by UITask when a CLI response is received
|
||||
void onCliResponse(const char* text) {
|
||||
if (_state != STATE_COMMAND_PENDING) return;
|
||||
|
||||
int tlen = strlen(text);
|
||||
if (tlen >= ADMIN_RESPONSE_MAX) tlen = ADMIN_RESPONSE_MAX - 1;
|
||||
memcpy(_response, text, tlen);
|
||||
_response[tlen] = '\0';
|
||||
_responseLen = tlen;
|
||||
_responseScroll = 0;
|
||||
_state = STATE_RESPONSE_VIEW;
|
||||
}
|
||||
|
||||
// Poll for timeouts
|
||||
void poll() override {
|
||||
if ((_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) &&
|
||||
_cmdSentAt > 0 && (millis() - _cmdSentAt) > ADMIN_TIMEOUT_MS) {
|
||||
snprintf(_response, sizeof(_response), "Timeout - no response.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[64];
|
||||
|
||||
// === Header ===
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
|
||||
// Truncate name if needed to fit header
|
||||
snprintf(tmp, sizeof(tmp), "Admin: %.16s", _repeaterName);
|
||||
display.print(tmp);
|
||||
|
||||
// Show permissions if logged in
|
||||
if (_state >= STATE_MENU && _state <= STATE_RESPONSE_VIEW) {
|
||||
const char* perm = (_permissions & 0x03) >= 3 ? "ADM" :
|
||||
(_permissions & 0x03) >= 2 ? "R/W" : "R/O";
|
||||
display.setCursor(display.width() - display.getTextWidth(perm) - 2, 0);
|
||||
display.print(perm);
|
||||
}
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1); // divider
|
||||
|
||||
// === Body - depends on state ===
|
||||
int bodyY = 14;
|
||||
int footerY = display.height() - 12;
|
||||
int bodyHeight = footerY - bodyY - 4;
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
renderPasswordEntry(display, bodyY);
|
||||
break;
|
||||
case STATE_LOGGING_IN:
|
||||
renderWaiting(display, bodyY, "Logging in...");
|
||||
break;
|
||||
case STATE_MENU:
|
||||
renderMenu(display, bodyY, bodyHeight);
|
||||
break;
|
||||
case STATE_COMMAND_PENDING:
|
||||
renderWaiting(display, bodyY, "Waiting...");
|
||||
break;
|
||||
case STATE_RESPONSE_VIEW:
|
||||
renderResponse(display, bodyY, bodyHeight);
|
||||
break;
|
||||
case STATE_ERROR:
|
||||
renderResponse(display, bodyY, bodyHeight); // reuse response renderer for errors
|
||||
break;
|
||||
}
|
||||
|
||||
// === Footer ===
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(1);
|
||||
display.setCursor(0, footerY);
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
display.print("Sh+Del:Exit");
|
||||
{
|
||||
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("Sh+Del:Cancel");
|
||||
break;
|
||||
case STATE_MENU:
|
||||
display.print("Sh+Del:Exit");
|
||||
{
|
||||
const char* mid = "W/S:Sel";
|
||||
const char* right = "Ent:Run";
|
||||
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("Sh+Del:Menu");
|
||||
if (_responseLen > bodyHeight / 9) { // if scrollable
|
||||
const char* right = "W/S:Scrll";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) return 1000;
|
||||
// During password reveal, refresh when the reveal expires
|
||||
if (_state == STATE_PASSWORD_ENTRY && _lastCharAt > 0 && (millis() - _lastCharAt) < 800) {
|
||||
return _lastCharAt + 800 - millis() + 50; // refresh shortly after reveal ends
|
||||
}
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
return handlePasswordInput(c);
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
// Shift+Del to cancel and go back
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
_state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case STATE_MENU:
|
||||
return handleMenuInput(c);
|
||||
case STATE_RESPONSE_VIEW:
|
||||
case STATE_ERROR:
|
||||
return handleResponseInput(c);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
// --- Password Entry ---
|
||||
void renderPasswordEntry(DisplayDriver& display, int y) {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
display.print("Password:");
|
||||
|
||||
y += 14;
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Show asterisks for password characters, with brief reveal of last char
|
||||
char masked[ADMIN_PASSWORD_MAX];
|
||||
int i;
|
||||
bool revealing = (_pwdLen > 0 && (millis() - _lastCharAt) < 800);
|
||||
int revealIdx = revealing ? _pwdLen - 1 : -1;
|
||||
for (i = 0; i < _pwdLen && i < ADMIN_PASSWORD_MAX - 1; i++) {
|
||||
masked[i] = (i == revealIdx) ? _password[i] : '*';
|
||||
}
|
||||
masked[i] = '\0';
|
||||
display.print(masked);
|
||||
|
||||
// Cursor indicator
|
||||
display.print("_");
|
||||
}
|
||||
|
||||
bool handlePasswordInput(char c) {
|
||||
// Shift+Del = exit (always, regardless of password content)
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
return false; // signal main.cpp to navigate back
|
||||
}
|
||||
|
||||
// Enter to submit
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
if (_pwdLen > 0) {
|
||||
return doLogin();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (c == 0x08 || c == 0x7F) {
|
||||
if (_pwdLen > 0) {
|
||||
_pwdLen--;
|
||||
_password[_pwdLen] = '\0';
|
||||
_lastCharAt = 0; // no reveal after delete
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Printable character
|
||||
if (c >= 32 && c < 127 && _pwdLen < ADMIN_PASSWORD_MAX - 1) {
|
||||
_password[_pwdLen++] = c;
|
||||
_password[_pwdLen] = '\0';
|
||||
_lastCharAt = millis(); // start brief reveal
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool doLogin(); // Defined below, calls into MyMesh
|
||||
|
||||
// --- Menu ---
|
||||
void renderMenu(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0); // tiny font for compact rows
|
||||
int lineHeight = 9;
|
||||
|
||||
// Show server time comparison if available
|
||||
if (_serverTime > 0) {
|
||||
char ourTime[12], srvTime[12];
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
formatTime(ourTime, sizeof(ourTime), now);
|
||||
formatTime(srvTime, sizeof(srvTime), _serverTime);
|
||||
|
||||
int drift = (int)(now - _serverTime);
|
||||
char driftStr[24];
|
||||
if (abs(drift) < 2) {
|
||||
snprintf(driftStr, sizeof(driftStr), "Synced");
|
||||
} else {
|
||||
snprintf(driftStr, sizeof(driftStr), "Drift:%+ds", drift);
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(0, y);
|
||||
char info[48];
|
||||
snprintf(info, sizeof(info), "Rpt:%s Us:%s %s", srvTime, ourTime, driftStr);
|
||||
display.print(info);
|
||||
y += lineHeight + 2;
|
||||
}
|
||||
|
||||
// Menu items
|
||||
for (int i = 0; i < MENU_COUNT && y + lineHeight <= display.height() - 16; i++) {
|
||||
bool selected = (i == _menuSel);
|
||||
|
||||
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(2, y);
|
||||
char label[32];
|
||||
snprintf(label, sizeof(label), "%s %s", selected ? ">" : " ", menuLabel((MenuItem)i));
|
||||
display.print(label);
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
bool handleMenuInput(char c) {
|
||||
// W/up - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_menuSel > 0) _menuSel--;
|
||||
return true;
|
||||
}
|
||||
// S/down - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
if (_menuSel < MENU_COUNT - 1) _menuSel++;
|
||||
return true;
|
||||
}
|
||||
// Enter - execute selected command
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
return executeMenuCommand((MenuItem)_menuSel);
|
||||
}
|
||||
// Shift+Del - back to contacts
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
return false; // let UITask handle back navigation
|
||||
}
|
||||
// Number keys for quick selection
|
||||
if (c >= '1' && c <= '0' + MENU_COUNT) {
|
||||
_menuSel = c - '1';
|
||||
return executeMenuCommand((MenuItem)_menuSel);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool executeMenuCommand(MenuItem item); // Defined below, calls into MyMesh
|
||||
|
||||
// --- Response View ---
|
||||
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(0); // tiny font for more content
|
||||
int lineHeight = 9;
|
||||
|
||||
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
|
||||
|
||||
// Render response text with word wrapping and scroll support
|
||||
int maxLines = bodyHeight / lineHeight;
|
||||
int lineCount = 0;
|
||||
int skipLines = _responseScroll;
|
||||
|
||||
const char* p = _response;
|
||||
char lineBuf[80];
|
||||
int lineWidth = display.width() - 4;
|
||||
|
||||
while (*p && lineCount < maxLines + skipLines) {
|
||||
// Extract next line (up to newline or screen width)
|
||||
int i = 0;
|
||||
while (*p && *p != '\n' && i < 79) {
|
||||
lineBuf[i++] = *p++;
|
||||
}
|
||||
lineBuf[i] = '\0';
|
||||
if (*p == '\n') p++;
|
||||
|
||||
if (lineCount >= skipLines && lineCount < skipLines + maxLines) {
|
||||
display.setCursor(2, y);
|
||||
display.print(lineBuf);
|
||||
y += lineHeight;
|
||||
}
|
||||
lineCount++;
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
bool handleResponseInput(char c) {
|
||||
// W/up - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_responseScroll > 0) {
|
||||
_responseScroll--;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// S/down - scroll down
|
||||
if (c == 's' || c == 'S' || c == 0xF1) {
|
||||
_responseScroll++;
|
||||
return true;
|
||||
}
|
||||
// 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;
|
||||
} else {
|
||||
_state = STATE_MENU;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Enter - also go back to menu
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
_state = STATE_MENU;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Waiting spinner ---
|
||||
void renderWaiting(DisplayDriver& display, int y, const char* msg) {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
int cx = (display.width() - display.getTextWidth(msg)) / 2;
|
||||
int cy = y + 20;
|
||||
display.setCursor(cx, cy);
|
||||
display.print(msg);
|
||||
|
||||
// Show elapsed time
|
||||
if (_cmdSentAt > 0) {
|
||||
char elapsed[16];
|
||||
unsigned long secs = (millis() - _cmdSentAt) / 1000;
|
||||
snprintf(elapsed, sizeof(elapsed), "%lus", secs);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor((display.width() - display.getTextWidth(elapsed)) / 2, cy + 14);
|
||||
display.print(elapsed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Implementations that require MyMesh (the_mesh is declared extern above) ---
|
||||
|
||||
inline bool RepeaterAdminScreen::doLogin() {
|
||||
if (_contactIdx < 0 || _pwdLen == 0) return false;
|
||||
|
||||
if (the_mesh.uiLoginToRepeater(_contactIdx, _password)) {
|
||||
_state = STATE_LOGGING_IN;
|
||||
_cmdSentAt = millis();
|
||||
_waitingForLogin = true;
|
||||
return true;
|
||||
} else {
|
||||
snprintf(_response, sizeof(_response), "Send failed.\nCheck contact path.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
inline bool RepeaterAdminScreen::executeMenuCommand(MenuItem item) {
|
||||
if (_contactIdx < 0) return false;
|
||||
|
||||
const char* cmd = menuCommand(item);
|
||||
if (cmd[0] == '\0') return false;
|
||||
|
||||
if (the_mesh.uiSendCliCommand(_contactIdx, cmd)) {
|
||||
_state = STATE_COMMAND_PENDING;
|
||||
_cmdSentAt = millis();
|
||||
_response[0] = '\0';
|
||||
_responseLen = 0;
|
||||
_responseScroll = 0;
|
||||
return true;
|
||||
} else {
|
||||
snprintf(_response, sizeof(_response), "Send failed.");
|
||||
_responseLen = strlen(_response);
|
||||
_state = STATE_ERROR;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
882
examples/companion_radio/ui-new/Settingsscreen.h
Normal file
882
examples/companion_radio/ui-new/Settingsscreen.h
Normal 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 Flash: %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);
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -603,7 +608,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 +618,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 +634,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 +644,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];
|
||||
@@ -855,12 +860,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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,7 +1030,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 +1059,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;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "UITask.h"
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
@@ -33,11 +35,13 @@
|
||||
#include "ChannelScreen.h"
|
||||
#include "ContactsScreen.h"
|
||||
#include "TextReaderScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.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 +88,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
|
||||
@@ -102,12 +111,18 @@ class HomeScreen : public UIScreen {
|
||||
NodePrefs* _node_prefs;
|
||||
uint8_t _page;
|
||||
bool _shutdown_init;
|
||||
bool _editing_utc;
|
||||
int8_t _saved_utc_offset; // for cancel/undo
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
||||
// Use voltage-based estimation to match BLE app readings
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
|
||||
uint8_t batteryPercentage = 0;
|
||||
#if HAS_BQ27220
|
||||
// Use fuel gauge SOC directly — accurate across the full discharge curve
|
||||
batteryPercentage = board.getBatteryPercent();
|
||||
#else
|
||||
// Fallback: voltage-based linear estimation for boards without fuel gauge
|
||||
if (batteryMilliVolts > 0) {
|
||||
const int minMilliVolts = 3000;
|
||||
const int maxMilliVolts = 4200;
|
||||
@@ -116,6 +131,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
if (pct > 100) pct = 100;
|
||||
batteryPercentage = (uint8_t)pct;
|
||||
}
|
||||
#endif
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
|
||||
@@ -183,7 +199,15 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
public:
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
_shutdown_init(false), sensors_lpp(200) { }
|
||||
_shutdown_init(false), _editing_utc(false), _saved_utc_offset(0), sensors_lpp(200) { }
|
||||
|
||||
bool isEditingUTC() const { return _editing_utc; }
|
||||
void cancelEditUTC() {
|
||||
if (_editing_utc) {
|
||||
_node_prefs->utc_offset_hours = _saved_utc_offset;
|
||||
_editing_utc = false;
|
||||
}
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
|
||||
@@ -193,17 +217,40 @@ 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());
|
||||
|
||||
// centered clock (tinyfont) - only show when time is valid
|
||||
{
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
if (now > 1700000000) { // valid timestamp (after ~Nov 2023)
|
||||
// Apply UTC offset from prefs
|
||||
int32_t local = (int32_t)now + ((int32_t)_node_prefs->utc_offset_hours * 3600);
|
||||
int hrs = (local / 3600) % 24;
|
||||
if (hrs < 0) hrs += 24;
|
||||
int mins = (local / 60) % 60;
|
||||
if (mins < 0) mins += 60;
|
||||
|
||||
char timeBuf[6];
|
||||
sprintf(timeBuf, "%02d:%02d", hrs, mins);
|
||||
|
||||
display.setTextSize(0); // tinyfont
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
uint16_t tw = display.getTextWidth(timeBuf);
|
||||
int clockX = (display.width() - tw) / 2;
|
||||
display.setCursor(clockX, -3); // align with battery text Y
|
||||
display.print(timeBuf);
|
||||
display.setTextSize(1); // restore
|
||||
}
|
||||
}
|
||||
// curr page indicator
|
||||
int y = 14;
|
||||
int x = display.width() / 2 - 5 * (HomePage::Count-1);
|
||||
@@ -216,28 +263,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 ");
|
||||
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);
|
||||
@@ -282,6 +355,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,
|
||||
@@ -289,27 +363,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");
|
||||
@@ -321,6 +412,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.);
|
||||
@@ -331,6 +435,42 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
}
|
||||
// Show RTC time and UTC offset on GPS page
|
||||
{
|
||||
uint32_t now = _rtc->getCurrentTime();
|
||||
if (now > 1700000000) {
|
||||
int32_t local = (int32_t)now + ((int32_t)_node_prefs->utc_offset_hours * 3600);
|
||||
int hrs = (local / 3600) % 24;
|
||||
if (hrs < 0) hrs += 24;
|
||||
int mins = (local / 60) % 60;
|
||||
if (mins < 0) mins += 60;
|
||||
display.drawTextLeftAlign(0, y, "time(U)");
|
||||
sprintf(buf, "%02d:%02d UTC%+d", hrs, mins, _node_prefs->utc_offset_hours);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
} else {
|
||||
display.drawTextLeftAlign(0, y, "time(U)");
|
||||
display.drawTextRightAlign(display.width()-1, y, "no sync");
|
||||
}
|
||||
}
|
||||
// UTC offset editor overlay
|
||||
if (_editing_utc) {
|
||||
// Draw background box
|
||||
int bx = 4, by = 20, bw = display.width() - 8, bh = 40;
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(bx, by, bw, bh);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(bx, by, bw, bh);
|
||||
|
||||
// Show current offset value
|
||||
display.setTextSize(2);
|
||||
sprintf(buf, "UTC%+d", _node_prefs->utc_offset_hours);
|
||||
display.drawTextCentered(display.width() / 2, by + 4, buf);
|
||||
|
||||
// Show controls hint
|
||||
display.setTextSize(0);
|
||||
display.drawTextCentered(display.width() / 2, by + bh - 10, "W/S:adj Enter:ok Q:cancel");
|
||||
display.setTextSize(1);
|
||||
}
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
} else if (_page == HomePage::SENSORS) {
|
||||
@@ -403,6 +543,51 @@ 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);
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -414,10 +599,44 @@ public:
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL);
|
||||
}
|
||||
}
|
||||
return 5000; // next render after 5000 ms
|
||||
return _editing_utc ? 700 : 5000; // match e-ink refresh cycle while editing UTC
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// UTC offset editing mode - intercept all keys
|
||||
if (_editing_utc) {
|
||||
if (c == 'w' || c == KEY_PREV) {
|
||||
// Increment offset
|
||||
if (_node_prefs->utc_offset_hours < 14) {
|
||||
_node_prefs->utc_offset_hours++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == 's' || c == KEY_NEXT) {
|
||||
// Decrement offset
|
||||
if (_node_prefs->utc_offset_hours > -12) {
|
||||
_node_prefs->utc_offset_hours--;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
// Save and exit
|
||||
Serial.printf("UTC offset saving: %d\n", _node_prefs->utc_offset_hours);
|
||||
the_mesh.savePrefs();
|
||||
_editing_utc = false;
|
||||
_task->showAlert("UTC offset saved", 800);
|
||||
Serial.println("UTC offset save complete");
|
||||
return true;
|
||||
}
|
||||
if (c == 'q' || c == 'u') {
|
||||
// Cancel - restore original value
|
||||
_node_prefs->utc_offset_hours = _saved_utc_offset;
|
||||
_editing_utc = false;
|
||||
return true;
|
||||
}
|
||||
return true; // Consume all other keys while editing
|
||||
}
|
||||
|
||||
if (c == KEY_LEFT || c == KEY_PREV) {
|
||||
_page = (_page + HomePage::Count - 1) % HomePage::Count;
|
||||
return true;
|
||||
@@ -429,6 +648,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#ifdef BLE_PIN_CODE
|
||||
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
||||
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableSerial();
|
||||
@@ -437,6 +657,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -451,6 +672,11 @@ public:
|
||||
_task->toggleGPS();
|
||||
return true;
|
||||
}
|
||||
if (c == 'u' && _page == HomePage::GPS) {
|
||||
_editing_utc = true;
|
||||
_saved_utc_offset = _node_prefs->utc_offset_hours;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
if (c == KEY_ENTER && _page == HomePage::SENSORS) {
|
||||
@@ -599,6 +825,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;
|
||||
|
||||
@@ -608,6 +840,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 = new RepeaterAdminScreen(this, &rtc_clock);
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -695,6 +931,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() {
|
||||
@@ -830,6 +1074,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
|
||||
@@ -927,39 +1179,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() {
|
||||
@@ -986,11 +1235,22 @@ void UITask::injectKey(char c) {
|
||||
}
|
||||
curr->handleInput(c);
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer
|
||||
_next_refresh = 100; // trigger refresh
|
||||
// Debounce refresh when editing UTC offset - e-ink takes 644ms per refresh
|
||||
// so don't queue another render until the current one could have finished
|
||||
if (isEditingHomeScreen()) {
|
||||
unsigned long earliest = millis() + 700;
|
||||
if (_next_refresh < earliest) {
|
||||
_next_refresh = earliest;
|
||||
}
|
||||
} else {
|
||||
_next_refresh = 100; // trigger refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::gotoHomeScreen() {
|
||||
// Cancel any active editing state when navigating to home
|
||||
((HomeScreen *) home)->cancelEditUTC();
|
||||
setCurrScreen(home);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
@@ -999,6 +1259,10 @@ void UITask::gotoHomeScreen() {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
bool UITask::isEditingHomeScreen() const {
|
||||
return curr == home && ((HomeScreen *) home)->isEditingUTC();
|
||||
}
|
||||
|
||||
void UITask::gotoChannelScreen() {
|
||||
((ChannelScreen *) channel_screen)->resetScroll();
|
||||
setCurrScreen(channel_screen);
|
||||
@@ -1032,6 +1296,69 @@ 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
|
||||
setCurrScreen(audiobook_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
|
||||
}
|
||||
@@ -1043,4 +1370,18 @@ 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::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (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 (isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
@@ -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* repeater_admin; // Repeater admin screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
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,12 +98,19 @@ public:
|
||||
bool isOnChannelScreen() const { return curr == channel_screen; }
|
||||
bool isOnContactsScreen() const { return curr == contacts_screen; }
|
||||
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
|
||||
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; }
|
||||
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);
|
||||
|
||||
@@ -103,7 +121,13 @@ public:
|
||||
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; }
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
@@ -111,5 +135,9 @@ public:
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
// Repeater admin callbacks (from MyMesh via AbstractUITask)
|
||||
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
|
||||
void onAdminCliResponse(const char* from_name, const char* text) override;
|
||||
|
||||
void shutdown(bool restart = false);
|
||||
};
|
||||
152
examples/companion_radio/ui-new/Utf8cp437.h
Normal file
152
examples/companion_radio/ui-new/Utf8cp437.h
Normal 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
@@ -249,4 +249,4 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
|
||||
|
||||
bool SerialBLEInterface::isConnected() const {
|
||||
return deviceConnected; //pServer != NULL && pServer->getConnectedCount() > 0;
|
||||
}
|
||||
}
|
||||
@@ -88,4 +88,4 @@ public:
|
||||
#else
|
||||
#define BLE_DEBUG_PRINT(...) {}
|
||||
#define BLE_DEBUG_PRINTLN(...) {}
|
||||
#endif
|
||||
#endif
|
||||
70
variants/lilygo_tdeck_pro/CPUPowerManager.h
Normal file
70
variants/lilygo_tdeck_pro/CPUPowerManager.h
Normal 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
|
||||
185
variants/lilygo_tdeck_pro/GPSDutyCycle.h
Normal file
185
variants/lilygo_tdeck_pro/GPSDutyCycle.h
Normal 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
|
||||
72
variants/lilygo_tdeck_pro/GPSStreamCounter.h
Normal file
72
variants/lilygo_tdeck_pro/GPSStreamCounter.h
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -80,6 +80,7 @@ build_flags =
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D CST328_PIN_RST=38
|
||||
-D FIRMWARE_VERSION='"Meck v0.8.9"'
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_TDeck_Pro>
|
||||
+<helpers/sensors/*.cpp>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "TechoBoard.h"
|
||||
|
||||
#ifdef LILYGO_TECHO
|
||||
|
||||
void TechoBoard::begin() {
|
||||
NRF52Board::begin();
|
||||
|
||||
Wire.begin();
|
||||
|
||||
pinMode(SX126X_POWER_EN, OUTPUT);
|
||||
digitalWrite(SX126X_POWER_EN, HIGH);
|
||||
delay(10); // give sx1262 some time to power up
|
||||
}
|
||||
|
||||
uint16_t TechoBoard::getBattMilliVolts() {
|
||||
int adcvalue = 0;
|
||||
|
||||
analogReference(AR_INTERNAL_3_0);
|
||||
analogReadResolution(12);
|
||||
delay(10);
|
||||
|
||||
// ADC range is 0..3000mV and resolution is 12-bit (0..4095)
|
||||
adcvalue = analogRead(PIN_VBAT_READ);
|
||||
// Convert the raw value to compensated mv, taking the resistor-
|
||||
// divider into account (providing the actual LIPO voltage)
|
||||
return (uint16_t)((float)adcvalue * REAL_VBAT_MV_PER_LSB);
|
||||
}
|
||||
#endif
|
||||
@@ -1,44 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <MeshCore.h>
|
||||
#include <Arduino.h>
|
||||
#include <helpers/NRF52Board.h>
|
||||
|
||||
// built-ins
|
||||
#define VBAT_MV_PER_LSB (0.73242188F) // 3.0V ADC range and 12-bit ADC resolution = 3000mV/4096
|
||||
|
||||
#define VBAT_DIVIDER (0.5F) // 150K + 150K voltage divider on VBAT
|
||||
#define VBAT_DIVIDER_COMP (2.0F) // Compensation factor for the VBAT divider
|
||||
|
||||
#define PIN_VBAT_READ (4)
|
||||
#define REAL_VBAT_MV_PER_LSB (VBAT_DIVIDER_COMP * VBAT_MV_PER_LSB)
|
||||
|
||||
class TechoBoard : public NRF52BoardOTA {
|
||||
public:
|
||||
TechoBoard() : NRF52BoardOTA("TECHO_OTA") {}
|
||||
void begin();
|
||||
uint16_t getBattMilliVolts() override;
|
||||
|
||||
const char* getManufacturerName() const override {
|
||||
return "LilyGo T-Echo";
|
||||
}
|
||||
|
||||
void powerOff() override {
|
||||
#ifdef LED_RED
|
||||
digitalWrite(LED_RED, LOW);
|
||||
#endif
|
||||
#ifdef LED_GREEN
|
||||
digitalWrite(LED_GREEN, LOW);
|
||||
#endif
|
||||
#ifdef LED_BLUE
|
||||
digitalWrite(LED_BLUE, LOW);
|
||||
#endif
|
||||
#ifdef DISP_BACKLIGHT
|
||||
digitalWrite(DISP_BACKLIGHT, LOW);
|
||||
#endif
|
||||
#ifdef PIN_PWR_EN
|
||||
digitalWrite(PIN_PWR_EN, LOW);
|
||||
#endif
|
||||
sd_power_system_off();
|
||||
}
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
[LilyGo_T-Echo-Lite]
|
||||
extends = nrf52_base
|
||||
board = t-echo
|
||||
board_build.ldscript = boards/nrf52840_s140_v6.ld
|
||||
build_flags = ${nrf52_base.build_flags}
|
||||
-I variants/lilygo_techo_lite
|
||||
-I src/helpers/nrf52
|
||||
-I lib/nrf52/s140_nrf52_6.1.1_API/include
|
||||
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
|
||||
-D LILYGO_TECHO
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
-D SX126X_POWER_EN=30
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D P_LORA_TX_LED=LED_GREEN
|
||||
-D DISABLE_DIAGNOSTIC_OUTPUT
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-D GPS_BAUD_RATE=9600
|
||||
-D PIN_GPS_EN=GPS_EN
|
||||
-D DISPLAY_CLASS=GxEPDDisplay
|
||||
-D EINK_DISPLAY_MODEL=GxEPD2_122_T61
|
||||
-D EINK_SCALE_X=1.5f
|
||||
-D EINK_SCALE_Y=2.0f
|
||||
-D EINK_X_OFFSET=0
|
||||
-D EINK_Y_OFFSET=10
|
||||
-D DISPLAY_ROTATION=4
|
||||
-D AUTO_OFF_MILLIS=0
|
||||
build_src_filter = ${nrf52_base.build_src_filter}
|
||||
+<helpers/*.cpp>
|
||||
+<TechoBoard.cpp>
|
||||
+<helpers/sensors/EnvironmentSensorManager.cpp>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
+<helpers/ui/MomentaryButton.cpp>
|
||||
+<../variants/lilygo_techo_lite>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
stevemarple/MicroNMEA @ ^2.0.6
|
||||
adafruit/Adafruit BME280 Library @ ^2.3.0
|
||||
https://github.com/SoulOfNoob/GxEPD2.git
|
||||
bakercp/CRC32 @ ^2.0.0
|
||||
debug_tool = jlink
|
||||
upload_protocol = nrfutil
|
||||
|
||||
[env:LilyGo_T-Echo-Lite_repeater]
|
||||
extends = LilyGo_T-Echo-Lite
|
||||
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
|
||||
+<../examples/simple_repeater>
|
||||
build_flags =
|
||||
${LilyGo_T-Echo-Lite.build_flags}
|
||||
-D ADVERT_NAME='"T-Echo-Lite Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
|
||||
[env:LilyGo_T-Echo-Lite_room_server]
|
||||
extends = LilyGo_T-Echo-Lite
|
||||
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
|
||||
+<../examples/simple_room_server>
|
||||
build_flags =
|
||||
${LilyGo_T-Echo-Lite.build_flags}
|
||||
-D ADVERT_NAME='"T-Echo-Lite Room"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
|
||||
[env:LilyGo_T-Echo-Lite_companion_radio_ble]
|
||||
extends = LilyGo_T-Echo-Lite
|
||||
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
|
||||
board_upload.maximum_size = 712704
|
||||
build_flags =
|
||||
${LilyGo_T-Echo-Lite.build_flags}
|
||||
-I src/helpers/ui
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
; -D QSPIFLASH=1
|
||||
-D BLE_PIN_CODE=123456
|
||||
; -D BLE_DEBUG_LOGGING=1
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D UI_RECENT_LIST_SIZE=9
|
||||
-D UI_SENSORS_PAGE=1
|
||||
; -D MESH_PACKET_LOGGING=1
|
||||
; -D MESH_DEBUG=1
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=3300
|
||||
build_src_filter = ${LilyGo_T-Echo-Lite.build_src_filter}
|
||||
+<helpers/nrf52/SerialBLEInterface.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
lib_deps =
|
||||
${LilyGo_T-Echo-Lite.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
@@ -1,52 +0,0 @@
|
||||
#include <Arduino.h>
|
||||
#include "target.h"
|
||||
#include <helpers/ArduinoHelpers.h>
|
||||
#include <helpers/sensors/MicroNMEALocationProvider.h>
|
||||
|
||||
TechoBoard board;
|
||||
|
||||
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI);
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
VolatileRTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
|
||||
#ifdef ENV_INCLUDE_GPS
|
||||
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
|
||||
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
|
||||
#else
|
||||
EnvironmentSensorManager sensors = EnvironmentSensorManager();
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display;
|
||||
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
rtc_clock.begin(Wire);
|
||||
|
||||
return radio.std_init(&SPI);
|
||||
}
|
||||
|
||||
uint32_t radio_get_rng_seed() {
|
||||
return radio.random(0x7FFFFFFF);
|
||||
}
|
||||
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
|
||||
radio.setFrequency(freq);
|
||||
radio.setSpreadingFactor(sf);
|
||||
radio.setBandwidth(bw);
|
||||
radio.setCodingRate(cr);
|
||||
}
|
||||
|
||||
void radio_set_tx_power(uint8_t dbm) {
|
||||
radio.setOutputPower(dbm);
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <TechoBoard.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
#include <helpers/sensors/LocationProvider.h>
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include <helpers/ui/GxEPDDisplay.h>
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
#endif
|
||||
|
||||
extern TechoBoard board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern DISPLAY_CLASS display;
|
||||
extern MomentaryButton user_btn;
|
||||
#endif
|
||||
|
||||
bool radio_init();
|
||||
uint32_t radio_get_rng_seed();
|
||||
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
|
||||
void radio_set_tx_power(uint8_t dbm);
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
@@ -1,39 +0,0 @@
|
||||
#include "variant.h"
|
||||
#include "wiring_constants.h"
|
||||
#include "wiring_digital.h"
|
||||
|
||||
const int MISO = PIN_SPI1_MISO;
|
||||
const int MOSI = PIN_SPI1_MOSI;
|
||||
const int SCK = PIN_SPI1_SCK;
|
||||
|
||||
const uint32_t g_ADigitalPinMap[] = {
|
||||
0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
|
||||
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
|
||||
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
|
||||
40, 41, 42, 43, 44, 45, 46, 47
|
||||
};
|
||||
|
||||
void initVariant() {
|
||||
pinMode(PIN_PWR_EN, OUTPUT);
|
||||
digitalWrite(PIN_PWR_EN, HIGH);
|
||||
|
||||
pinMode(PIN_BUTTON1, INPUT_PULLUP);
|
||||
pinMode(PIN_BUTTON2, INPUT_PULLUP);
|
||||
|
||||
pinMode(LED_RED, OUTPUT);
|
||||
pinMode(LED_GREEN, OUTPUT);
|
||||
pinMode(LED_BLUE, OUTPUT);
|
||||
digitalWrite(LED_BLUE, HIGH);
|
||||
digitalWrite(LED_GREEN, HIGH);
|
||||
digitalWrite(LED_RED, HIGH);
|
||||
|
||||
// pinMode(PIN_TXCO, OUTPUT);
|
||||
// digitalWrite(PIN_TXCO, HIGH);
|
||||
|
||||
pinMode(DISP_POWER, OUTPUT);
|
||||
digitalWrite(DISP_POWER, LOW);
|
||||
|
||||
// shutdown gps
|
||||
pinMode(GPS_EN, OUTPUT);
|
||||
digitalWrite(GPS_EN, LOW);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
* variant.h
|
||||
* Copyright (C) 2023 Seeed K.K.
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#define _PINNUM(port, pin) ((port) * 32 + (pin))
|
||||
|
||||
#include "WVariant.h"
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Low frequency clock source
|
||||
|
||||
#define USE_LFXO // 32.768 kHz crystal oscillator
|
||||
#define VARIANT_MCK (64000000ul)
|
||||
|
||||
#define WIRE_INTERFACES_COUNT (1)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Power
|
||||
|
||||
#define PIN_PWR_EN _PINNUM(0, 30) // RT9080_EN
|
||||
|
||||
#define BATTERY_PIN _PINNUM(0, 2)
|
||||
#define ADC_MULTIPLIER (4.90F)
|
||||
|
||||
#define ADC_RESOLUTION (14)
|
||||
#define BATTERY_SENSE_RES (12)
|
||||
|
||||
#define AREF_VOLTAGE (3.0)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Number of pins
|
||||
|
||||
#define PINS_COUNT (48)
|
||||
#define NUM_DIGITAL_PINS (48)
|
||||
#define NUM_ANALOG_INPUTS (1)
|
||||
#define NUM_ANALOG_OUTPUTS (0)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// UART pin definition
|
||||
|
||||
#define PIN_SERIAL1_RX PIN_GPS_TX
|
||||
#define PIN_SERIAL1_TX PIN_GPS_RX
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// I2C pin definition
|
||||
|
||||
#define PIN_WIRE_SDA _PINNUM(0, 4) // (SDA)
|
||||
#define PIN_WIRE_SCL _PINNUM(0, 2) // (SCL)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// SPI pin definition
|
||||
|
||||
#define SPI_INTERFACES_COUNT _PINNUM(0, 2)
|
||||
|
||||
#define PIN_SPI_MISO _PINNUM(0, 17) // (MISO)
|
||||
#define PIN_SPI_MOSI _PINNUM(0, 15) // (MOSI)
|
||||
#define PIN_SPI_SCK _PINNUM(0, 13) // (SCK)
|
||||
#define PIN_SPI_NSS (-1)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// QSPI FLASH
|
||||
|
||||
#define PIN_QSPI_SCK _PINNUM(0, 4)
|
||||
#define PIN_QSPI_CS _PINNUM(0, 12)
|
||||
#define PIN_QSPI_IO0 _PINNUM(0, 6)
|
||||
#define PIN_QSPI_IO1 _PINNUM(0, 8)
|
||||
#define PIN_QSPI_IO2 _PINNUM(1, 9)
|
||||
#define PIN_QSPI_IO3 _PINNUM(0, 26)
|
||||
|
||||
#define EXTERNAL_FLASH_DEVICES ZD25WQ32CEIGR
|
||||
#define EXTERNAL_FLASH_USE_QSPI
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Builtin LEDs
|
||||
|
||||
#define LED_RED _PINNUM(1, 14) // LED_3
|
||||
#define LED_BLUE _PINNUM(1, 5) // LED_2
|
||||
#define LED_GREEN _PINNUM(1, 7) // LED_1
|
||||
|
||||
//#define PIN_STATUS_LED LED_BLUE
|
||||
#define LED_BUILTIN (-1)
|
||||
#define LED_PIN LED_BUILTIN
|
||||
#define LED_STATE_ON LOW
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Builtin buttons
|
||||
|
||||
#define PIN_BUTTON1 _PINNUM(0, 24) // BOOT
|
||||
#define BUTTON_PIN PIN_BUTTON1
|
||||
#define PIN_USER_BTN BUTTON_PIN
|
||||
|
||||
#define PIN_BUTTON2 _PINNUM(0, 18)
|
||||
#define BUTTON_PIN2 PIN_BUTTON2
|
||||
|
||||
#define EXTERNAL_FLASH_DEVICES MX25R1635F
|
||||
#define EXTERNAL_FLASH_USE_QSPI
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Lora
|
||||
|
||||
#define USE_SX1262
|
||||
#define LORA_CS _PINNUM(0, 11)
|
||||
#define SX126X_POWER_EN _PINNUM(0, 30)
|
||||
#define SX126X_DIO1 _PINNUM(1, 8)
|
||||
#define SX126X_BUSY _PINNUM(0, 14)
|
||||
#define SX126X_RESET _PINNUM(0, 7)
|
||||
#define SX126X_RF_VC1 _PINNUM(0, 27)
|
||||
#define SX126X_RF_VC2 _PINNUM(0, 33)
|
||||
|
||||
#define P_LORA_DIO_1 SX126X_DIO1
|
||||
#define P_LORA_NSS LORA_CS
|
||||
#define P_LORA_RESET SX126X_RESET
|
||||
#define P_LORA_BUSY SX126X_BUSY
|
||||
#define P_LORA_SCLK PIN_SPI_SCK
|
||||
#define P_LORA_MISO PIN_SPI_MISO
|
||||
#define P_LORA_MOSI PIN_SPI_MOSI
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// SPI1
|
||||
|
||||
#define PIN_SPI1_MISO (-1) // Not used for Display
|
||||
#define PIN_SPI1_MOSI _PINNUM(0, 20)
|
||||
#define PIN_SPI1_SCK _PINNUM(0, 19)
|
||||
|
||||
// GxEPD2 needs that for a panel that is not even used !
|
||||
extern const int MISO;
|
||||
extern const int MOSI;
|
||||
extern const int SCK;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Display
|
||||
|
||||
// #define DISP_MISO (-1) // Not used for Display
|
||||
#define DISP_MOSI _PINNUM(0, 20)
|
||||
#define DISP_SCLK _PINNUM(0, 19)
|
||||
#define DISP_CS _PINNUM(0, 22)
|
||||
#define DISP_DC _PINNUM(0, 21)
|
||||
#define DISP_RST _PINNUM(0, 28)
|
||||
#define DISP_BUSY _PINNUM(0, 3)
|
||||
#define DISP_POWER _PINNUM(1, 12)
|
||||
// #define DISP_BACKLIGHT (-1) // Display has no backlight
|
||||
|
||||
#define PIN_DISPLAY_CS DISP_CS
|
||||
#define PIN_DISPLAY_DC DISP_DC
|
||||
#define PIN_DISPLAY_RST DISP_RST
|
||||
#define PIN_DISPLAY_BUSY DISP_BUSY
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// GPS
|
||||
|
||||
#define PIN_GPS_RX _PINNUM(1, 13) // RXD
|
||||
#define PIN_GPS_TX _PINNUM(1, 15) // TXD
|
||||
#define GPS_EN _PINNUM(1, 11) // POWER_RT9080_EN
|
||||
#define PIN_GPS_STANDBY _PINNUM(1, 10)
|
||||
#define PIN_GPS_PPS _PINNUM(0, 29) // 1PPS
|
||||
Reference in New Issue
Block a user