mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
20 Commits
duty-cycle
...
audio-upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d1b99b77 | ||
|
|
e056ea3c2c | ||
|
|
b2967fc1a7 | ||
|
|
addcbcd00e | ||
|
|
8cdf19a848 | ||
|
|
5019d12fb0 | ||
|
|
306e9815b4 | ||
|
|
0a892f2dad | ||
|
|
b1e3f2ac28 | ||
|
|
4683711877 | ||
|
|
9610277b83 | ||
|
|
745efc4cc1 | ||
|
|
7223395740 | ||
|
|
9ef1fa4f1b | ||
|
|
2dd5c4f59f | ||
|
|
ee2a27258b | ||
|
|
5b868d51ca | ||
|
|
220006c229 | ||
|
|
a60f4146d5 | ||
|
|
017b170e81 |
@@ -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));
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "11 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "15 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.8.4"
|
||||
#define FIRMWARE_VERSION "Meck v0.8.9"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
#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);
|
||||
@@ -25,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;
|
||||
@@ -43,6 +45,9 @@
|
||||
// Text reader mode state
|
||||
static bool readerMode = false;
|
||||
|
||||
// Notes mode state
|
||||
static bool notesMode = false;
|
||||
|
||||
// Power management
|
||||
#if HAS_GPS
|
||||
GPSDutyCycle gpsDuty;
|
||||
@@ -400,7 +405,7 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Early SD card init — needed BEFORE the_mesh.begin() so we can restore
|
||||
// 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -508,6 +513,12 @@ void setup() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -530,7 +541,7 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// GPS duty cycle — honour saved pref, default to enabled on first boot
|
||||
// GPS duty cycle  honour saved pref, default to enabled on first boot
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
|
||||
@@ -545,7 +556,7 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
// CPU frequency scaling  drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
// T-Deck Pro: BLE starts disabled for standalone-first operation
|
||||
@@ -561,7 +572,7 @@ void setup() {
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
|
||||
// GPS duty cycle — check for fix and manage power state
|
||||
// GPS duty cycle  check for fix and manage power state
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_hw_on = gpsDuty.loop();
|
||||
@@ -581,22 +592,33 @@ void 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
|
||||
@@ -810,6 +832,141 @@ void handleKeyboardInput() {
|
||||
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();
|
||||
@@ -826,11 +983,61 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys → settings screen via injectKey
|
||||
// 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':
|
||||
@@ -851,9 +1058,23 @@ void handleKeyboardInput() {
|
||||
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
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
// 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");
|
||||
@@ -863,7 +1084,7 @@ void handleKeyboardInput() {
|
||||
|
||||
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");
|
||||
@@ -892,7 +1113,8 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case '\r':
|
||||
// Enter = compose (only from channel or contacts screen)
|
||||
// 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();
|
||||
@@ -907,8 +1129,15 @@ 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) {
|
||||
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;
|
||||
@@ -928,7 +1157,7 @@ void handleKeyboardInput() {
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
@@ -12,6 +12,7 @@ extern MyMesh the_mesh;
|
||||
#define ADMIN_PASSWORD_MAX 32
|
||||
#define ADMIN_RESPONSE_MAX 512 // CLI responses can be multi-line
|
||||
#define ADMIN_TIMEOUT_MS 15000 // 15s timeout for login/commands
|
||||
#define KEY_ADMIN_EXIT 0xFE // Special key: Shift+Backspace exit (injected by main.cpp)
|
||||
|
||||
class RepeaterAdminScreen : public UIScreen {
|
||||
public:
|
||||
@@ -280,31 +281,34 @@ public:
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
display.print("Q:Back");
|
||||
display.print("Sh+Del:Exit");
|
||||
{
|
||||
const char* right = "Enter:Login";
|
||||
const char* right = "Ent:Login";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
display.print("Q:Cancel");
|
||||
display.print("Sh+Del:Cancel");
|
||||
break;
|
||||
case STATE_MENU:
|
||||
display.print("Q:Back");
|
||||
display.print("Sh+Del:Exit");
|
||||
{
|
||||
const char* mid = "W/S:Sel";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
const char* right = "Ent:Run";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
int leftEnd = display.getTextWidth("Sh+Del:Exit") + 2;
|
||||
int rightStart = display.width() - display.getTextWidth(right) - 2;
|
||||
int midX = leftEnd + (rightStart - leftEnd - display.getTextWidth(mid)) / 2;
|
||||
display.setCursor(midX, footerY);
|
||||
display.print(mid);
|
||||
display.setCursor(rightStart, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
case STATE_RESPONSE_VIEW:
|
||||
case STATE_ERROR:
|
||||
display.print("Q:Menu");
|
||||
display.print("Sh+Del:Menu");
|
||||
if (_responseLen > bodyHeight / 9) { // if scrollable
|
||||
const char* right = "W/S:Scrll";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
@@ -327,8 +331,8 @@ public:
|
||||
return handlePasswordInput(c);
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
// Q to cancel and go back
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del to cancel and go back
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
_state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU;
|
||||
return true;
|
||||
}
|
||||
@@ -370,9 +374,9 @@ private:
|
||||
}
|
||||
|
||||
bool handlePasswordInput(char c) {
|
||||
// Q without any password typed = go back (return false to signal "not handled")
|
||||
if ((c == 'q' || c == 'Q') && _pwdLen == 0) {
|
||||
return false;
|
||||
// Shift+Del = exit (always, regardless of password content)
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
return false; // signal main.cpp to navigate back
|
||||
}
|
||||
|
||||
// Enter to submit
|
||||
@@ -472,8 +476,8 @@ private:
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
return executeMenuCommand((MenuItem)_menuSel);
|
||||
}
|
||||
// Q - back to contacts
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del - back to contacts
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
return false; // let UITask handle back navigation
|
||||
}
|
||||
// Number keys for quick selection
|
||||
@@ -535,8 +539,8 @@ private:
|
||||
_responseScroll++;
|
||||
return true;
|
||||
}
|
||||
// Q - back to menu (or back to password on error)
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del - back to menu (or back to password on error)
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
if (_state == STATE_ERROR && _permissions == 0) {
|
||||
// Not yet logged in, go back to password
|
||||
_state = STATE_PASSWORD_ENTRY;
|
||||
|
||||
@@ -24,10 +24,22 @@ struct RadioPreset {
|
||||
};
|
||||
|
||||
static const RadioPreset RADIO_PRESETS[] = {
|
||||
{ "MeshCore Default", 915.0f, 250.0f, 10, 5, 20 },
|
||||
{ "Long Range", 915.0f, 125.0f, 12, 8, 20 },
|
||||
{ "Fast/Short", 915.0f, 500.0f, 7, 5, 20 },
|
||||
{ "EU Default", 869.4f, 250.0f, 10, 5, 14 },
|
||||
{ "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]))
|
||||
|
||||
@@ -72,7 +84,7 @@ private:
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
// Row table — rebuilt whenever channels change
|
||||
// Row table — rebuilt whenever channels change
|
||||
struct Row {
|
||||
SettingsRowType type;
|
||||
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
|
||||
@@ -96,7 +108,7 @@ private:
|
||||
// Onboarding mode
|
||||
bool _onboarding;
|
||||
|
||||
// Dirty flag for radio params — prompt to apply
|
||||
// Dirty flag for radio params — prompt to apply
|
||||
bool _radioChanged;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -198,11 +210,11 @@ private:
|
||||
strncpy(newCh.name, chanName, sizeof(newCh.name));
|
||||
newCh.name[31] = '\0';
|
||||
|
||||
// SHA-256 the channel name → first 16 bytes become the secret
|
||||
// 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
|
||||
// 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++) {
|
||||
@@ -400,8 +412,8 @@ public:
|
||||
}
|
||||
|
||||
case ROW_FREQ:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %.3f <W/S>", _editFloat);
|
||||
if (editing && _editMode == EDIT_TEXT) {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %s_ MHz", _editBuf);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %.3f MHz", _prefs->freq);
|
||||
}
|
||||
@@ -611,6 +623,15 @@ public:
|
||||
_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);
|
||||
@@ -684,7 +705,6 @@ public:
|
||||
|
||||
if (c == 'w' || c == 'W') {
|
||||
switch (type) {
|
||||
case ROW_FREQ: _editFloat += 0.1f; break;
|
||||
case ROW_BW:
|
||||
// Cycle through common bandwidths
|
||||
if (_editFloat < 31.25f) _editFloat = 31.25f;
|
||||
@@ -703,7 +723,6 @@ public:
|
||||
}
|
||||
if (c == 's' || c == 'S') {
|
||||
switch (type) {
|
||||
case ROW_FREQ: _editFloat -= 0.1f; break;
|
||||
case ROW_BW:
|
||||
if (_editFloat > 250.0f) _editFloat = 250.0f;
|
||||
else if (_editFloat > 125.0f) _editFloat = 125.0f;
|
||||
@@ -721,10 +740,6 @@ public:
|
||||
if (c == '\r' || c == 13) {
|
||||
// Confirm number edit
|
||||
switch (type) {
|
||||
case ROW_FREQ:
|
||||
_prefs->freq = constrain(_editFloat, 400.0f, 2500.0f);
|
||||
_radioChanged = true;
|
||||
break;
|
||||
case ROW_BW:
|
||||
_prefs->bw = _editFloat;
|
||||
_radioChanged = true;
|
||||
@@ -787,9 +802,13 @@ public:
|
||||
case ROW_RADIO_PRESET:
|
||||
startEditPicker(max(0, detectCurrentPreset()));
|
||||
break;
|
||||
case ROW_FREQ:
|
||||
startEditFloat(_prefs->freq);
|
||||
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;
|
||||
@@ -828,7 +847,7 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// Q: back — if radio changed, prompt to apply first
|
||||
// Q: back — if radio changed, prompt to apply first
|
||||
if (c == 'q' || c == 'Q') {
|
||||
if (_radioChanged) {
|
||||
_editMode = EDIT_CONFIRM;
|
||||
|
||||
@@ -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) {
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,8 +911,9 @@ private:
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
|
||||
char status[20];
|
||||
sprintf(status, "%d/%d", _currentPage + 1, _totalPages);
|
||||
char status[30];
|
||||
int pct = _totalPages > 1 ? (_currentPage * 100) / (_totalPages - 1) : 100;
|
||||
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
|
||||
display.setCursor(0, footerY);
|
||||
display.print(status);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "UITask.h"
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
@@ -35,11 +36,12 @@
|
||||
#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) {
|
||||
@@ -86,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
|
||||
@@ -109,9 +116,13 @@ class HomeScreen : public UIScreen {
|
||||
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;
|
||||
@@ -120,6 +131,7 @@ void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts)
|
||||
if (pct > 100) pct = 100;
|
||||
batteryPercentage = (uint8_t)pct;
|
||||
}
|
||||
#endif
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
|
||||
@@ -205,12 +217,12 @@ 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
|
||||
@@ -251,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);
|
||||
@@ -317,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,
|
||||
@@ -324,6 +363,7 @@ 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);
|
||||
@@ -373,7 +413,7 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter — confirms baud rate and data flow
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (gpsDuty.isHardwareOn()) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
@@ -503,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);
|
||||
@@ -563,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();
|
||||
@@ -571,6 +657,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -747,7 +834,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);
|
||||
}
|
||||
|
||||
@@ -789,7 +879,7 @@ switch(t){
|
||||
|
||||
void UITask::msgRead(int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
if (msgcount == 0) {
|
||||
if (msgcount == 0 && curr == msg_preview) {
|
||||
gotoHomeScreen();
|
||||
}
|
||||
}
|
||||
@@ -818,9 +908,12 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via 'M' key
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
// Suppress alert entirely on admin screen - it needs focused interaction
|
||||
if (!isOnRepeaterAdmin()) {
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
}
|
||||
#else
|
||||
// Other devices: Show full preview screen (legacy behavior)
|
||||
setCurrScreen(msg_preview);
|
||||
@@ -1080,13 +1173,13 @@ void UITask::toggleGPS() {
|
||||
|
||||
if (_sensors != NULL) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS — cut hardware power
|
||||
// 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
|
||||
// Enable GPS  start duty cycle
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
@@ -1184,6 +1277,19 @@ 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);
|
||||
@@ -1204,6 +1310,36 @@ void UITask::gotoOnboarding() {
|
||||
_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();
|
||||
}
|
||||
@@ -1215,4 +1351,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
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,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();
|
||||
@@ -80,8 +83,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; }
|
||||
@@ -90,7 +96,10 @@ 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();
|
||||
@@ -110,9 +119,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;
|
||||
@@ -120,5 +133,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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user